diff --git a/Cargo.lock b/Cargo.lock index 77e949e90..6b1836a7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1198,6 +1198,14 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-jmap" +version = "0.1.0" +dependencies = [ + "proptest", + "serde_json", +] + [[package]] name = "craft-jpeg" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b2e9f8cc7..d4a848d85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ craft-fig = { path = "crates/fig" } craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } craft-idml = { path = "crates/idml" } +craft-jmap = { path = "crates/jmap" } craft-ical = { path = "crates/ical" } craft-jpeg = { path = "crates/jpeg" } craft-kiwi = { path = "crates/kiwi" } diff --git a/README.md b/README.md index 02681ea6b..d3a8debf8 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + } + } + + /// The TCP port used when an address names none (RFC 9110 §4.2.1 and §4.2.2). + pub fn default_port(self) -> u16 { + match self { + Self::Http => 80, + Self::Https => 443, + } + } +} + +/// Why a text is not a usable address. +/// +/// No variant carries the text that was refused: it may hold a password or a token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum AddressError { + /// Nothing was given. + Empty, + /// Longer than [`MAX_ADDRESS_LEN`]. + TooLong, + /// A space, a control character, a character outside ASCII, or a character no URI may contain. + ForbiddenCharacter, + /// A backslash, which browsers read as a slash and RFC 3986 does not allow. + Backslash, + /// No scheme where an absolute address was needed. + MissingScheme, + /// A scheme other than `http` or `https`. + UnsupportedScheme, + /// No `//` and authority after the scheme. + MissingAuthority, + /// The host is empty (RFC 9110 §4.2.1). + EmptyHost, + /// User information (`name@` or `name:password@`) before the host (RFC 9110 §4.2.4). + UserInfo, + /// The host is not a usable name. + InvalidHost, + /// The host is outside ASCII; it must be given in its `xn--` form. + InternationalHost, + /// A numeric host that is not a plain dotted IPv4 address (`127.1`, `0x7f.0.0.1`, …). + AmbiguousNumericHost, + /// A bracketed IPv6 literal that does not parse. + InvalidIpv6, + /// The port is not a number from 1 to 65535. + InvalidPort, + /// The path holds a character or an escape that RFC 3986 §3.3 does not allow. + InvalidPath, + /// The query holds a character or an escape that RFC 3986 §3.4 does not allow. + InvalidQuery, + /// The fragment holds a character or an escape that RFC 3986 §3.5 does not allow. + InvalidFragment, + /// Plain `http` to a host that is not loopback. + InsecureTransport, + /// An address on this computer or its local network, given by a host farther away (see + /// [`HostScope`](crate::host::HostScope)). + LocalFromRemote, +} + +impl fmt::Display for AddressError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Empty => "no address was entered; type the JMAP server's address, for example example.com", + Self::TooLong => "the address is longer than 8 KiB (8192 characters), more than any server address needs; check that nothing else was pasted with it", + Self::ForbiddenCharacter => "the address contains a space, a control character, a non-ASCII character or one of \" < > ^ ` { | }; remove it or percent-encode it", + Self::Backslash => "the address contains a backslash (\\); addresses use forward slashes (/)", + Self::MissingScheme => "the address does not say how to connect; start it with https://", + Self::UnsupportedScheme => "the address uses a scheme other than https; enter an https:// address (http is only for this computer)", + Self::MissingAuthority => "the scheme is not followed by // and a server name; write it as https://server.example/path", + Self::EmptyHost => "the address has no server name after https://; add the server's name", + Self::UserInfo => "the address contains a user name followed by @ before the server name; remove it and enter the user name in the sign-in fields", + Self::InvalidHost => "the server name contains characters a DNS name cannot hold, or an empty or overlong label; check its spelling", + Self::InternationalHost => "the server name has non-ASCII letters; enter its ASCII form, which starts with xn--", + Self::AmbiguousNumericHost => "the server name looks numeric but is not a plain IPv4 address; write four numbers such as 192.0.2.1, without leading zeros, or use the server's name", + Self::InvalidIpv6 => "the IPv6 address in brackets cannot be read; zone suffixes such as %eth0 and IPvFuture forms are not supported", + Self::InvalidPort => "the port after the server name is not a number from 1 to 65535", + Self::InvalidPath => "the path contains a character or a % sign that addresses do not allow there; percent-encode it", + Self::InvalidQuery => "the part after ? contains a character or a % sign that addresses do not allow there; percent-encode it", + Self::InvalidFragment => "the part after # contains a character or a % sign that addresses do not allow there", + Self::InsecureTransport => "this address would send the password unencrypted over plain http to another computer; use https:// (plain http is only accepted for localhost, 127.0.0.0/8 and [::1])", + Self::LocalFromRemote => "the server tried to send this program to this computer or its local network, which a remote server may not do; its configuration may be wrong or tampered with", + }) + } +} + +impl std::error::Error for AddressError {} + +/// An absolute `http` or `https` address in normal form. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Address { + text: String, + scheme: Scheme, + host: Host, + port: Option, + path_start: usize, + query_start: Option, +} + +/// The origin of an address: scheme, host and port (the tuple origin of the WHATWG URL Standard §4.7 for +/// `http` and `https`). Its `Display` form is `scheme://host` with `:port` only when it is not the default. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Origin { + scheme: Scheme, + host: Host, + port: Option, +} + +impl Origin { + /// The scheme. + pub fn scheme(&self) -> Scheme { + self.scheme + } + + /// The host. + pub fn host(&self) -> &Host { + &self.host + } + + /// The port, the scheme's default when none was given. + pub fn port(&self) -> u16 { + self.port.unwrap_or_else(|| self.scheme.default_port()) + } +} + +impl fmt::Display for Origin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}://{}", self.scheme.as_str(), self.host)?; + if let Some(port) = self.port { + write!(f, ":{port}")?; + } + Ok(()) + } +} + +impl fmt::Debug for Origin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +/// The parts of a URI reference (RFC 3986 Appendix B), the fragment already set aside. +struct Parts<'a> { + scheme: Option<&'a str>, + authority: Option<&'a str>, + path: &'a str, + query: Option<&'a str>, +} + +fn refuse_unusable_characters(input: &str) -> Result<(), AddressError> { + if input.is_empty() { + return Err(AddressError::Empty); + } + if input.len() > MAX_ADDRESS_LEN { + return Err(AddressError::TooLong); + } + for b in input.bytes() { + if b == b'\\' { + return Err(AddressError::Backslash); + } + if !(0x21..=0x7E).contains(&b) || matches!(b, b'"' | b'<' | b'>' | b'^' | b'`' | b'{' | b'|' | b'}') { + return Err(AddressError::ForbiddenCharacter); + } + } + Ok(()) +} + +fn looks_like_scheme(text: &str) -> bool { + let mut bytes = text.bytes(); + matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic()) && bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) +} + +/// Splits a reference into its components (RFC 3986 Appendix B); the fragment is dropped, as RFC 9110 §7.1 +/// leaves it out of every target. +fn reference_parts(reference: &str) -> Result, AddressError> { + let (without_fragment, fragment) = match reference.split_once('#') { + Some((head, fragment)) => (head, Some(fragment)), + None => (reference, None), + }; + if let Some(fragment) = fragment + && !chars_allowed(fragment, true) + { + return Err(AddressError::InvalidFragment); + } + let (scheme, rest) = match without_fragment.find([':', '/', '?']) { + Some(index) if without_fragment.get(index..).is_some_and(|tail| tail.starts_with(':')) && index > 0 => { + let (scheme, rest) = without_fragment.split_at_checked(index).ok_or(AddressError::MissingScheme)?; + (Some(scheme), rest.get(1..).unwrap_or("")) + } + _ => (None, without_fragment), + }; + let (authority, rest) = match rest.strip_prefix("//") { + Some(after) => { + let end = after.find(['/', '?']).unwrap_or(after.len()); + let (authority, rest) = after.split_at_checked(end).ok_or(AddressError::MissingAuthority)?; + (Some(authority), rest) + } + None => (None, rest), + }; + let (path, query) = match rest.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (rest, None), + }; + Ok(Parts { scheme, authority, path, query }) +} + +/// Whether every character of a path (`allow_question` false) or a query/fragment (`allow_question` true) is +/// allowed by RFC 3986 §3.3–§3.5, escapes included. +fn chars_allowed(text: &str, allow_question: bool) -> bool { + let mut bytes = text.bytes(); + while let Some(b) = bytes.next() { + let ok = match b { + b'%' => bytes.next().and_then(percent::hex_value).is_some() && bytes.next().and_then(percent::hex_value).is_some(), + b':' | b'@' | b'/' => true, + b'?' => allow_question, + _ => is_unreserved(b) || is_sub_delim(b), + }; + if !ok { + return false; + } + } + true +} + +fn port_number(text: &str, scheme: Scheme) -> Result, AddressError> { + if text.is_empty() { + return Ok(None); + } + let mut value: u32 = 0; + for b in text.bytes() { + let digit = match b { + b'0'..=b'9' => u32::from(b.saturating_sub(b'0')), + _ => return Err(AddressError::InvalidPort), + }; + value = value.checked_mul(10).and_then(|v| v.checked_add(digit)).filter(|v| *v <= u32::from(u16::MAX)).ok_or(AddressError::InvalidPort)?; + } + let port = u16::try_from(value).map_err(|_| AddressError::InvalidPort)?; + if port == 0 { + return Err(AddressError::InvalidPort); + } + Ok(if port == scheme.default_port() { None } else { Some(port) }) +} + +/// Separates an authority's host text (brackets kept for IPv6) from its port text, empty when absent. +fn separate_port(authority: &str) -> Result<(&str, &str), AddressError> { + if authority.starts_with('[') { + let close = authority.find(']').ok_or(AddressError::InvalidIpv6)?; + let end = close.checked_add(1).ok_or(AddressError::InvalidIpv6)?; + let (host, rest) = authority.split_at_checked(end).ok_or(AddressError::InvalidIpv6)?; + if rest.is_empty() { + return Ok((host, "")); + } + let port = rest.strip_prefix(':').ok_or(AddressError::InvalidHost)?; + return Ok((host, port)); + } + if authority.contains(['[', ']']) { + return Err(AddressError::InvalidHost); + } + Ok(authority.split_once(':').unwrap_or((authority, ""))) +} + +/// Removes the `.` and `..` segments of a path (RFC 3986 §5.2.4). +pub(crate) fn collapse_dot_segments(path: &str) -> String { + let mut input = path; + let mut output = String::with_capacity(path.len()); + while !input.is_empty() { + if let Some(rest) = input.strip_prefix("../") { + input = rest; + } else if let Some(rest) = input.strip_prefix("./") { + input = rest; + } else if input.starts_with("/./") { + input = input.get(2..).unwrap_or(""); + } else if input == "/." { + input = "/"; + } else if input.starts_with("/../") { + input = input.get(3..).unwrap_or(""); + drop_last_segment(&mut output); + } else if input == "/.." { + input = "/"; + drop_last_segment(&mut output); + } else if input == "." || input == ".." { + input = ""; + } else { + let end = input.get(1..).and_then(|after_first| after_first.find('/')).and_then(|index| index.checked_add(1)).unwrap_or(input.len()); + let (segment, rest) = input.split_at_checked(end).unwrap_or((input, "")); + output.push_str(segment); + input = rest; + } + } + output +} + +fn drop_last_segment(output: &mut String) { + match output.rfind('/') { + Some(index) => output.truncate(index), + None => output.clear(), + } +} + +/// Merges a relative path with the path of a base that has an authority (RFC 3986 §5.2.3). +fn base_directory_plus(base_path: &str, reference_path: &str) -> String { + let directory = match base_path.rfind('/') { + Some(index) => base_path.get(..=index).unwrap_or("/"), + None => "/", + }; + let mut merged = String::with_capacity(directory.len().saturating_add(reference_path.len())); + merged.push_str(directory); + merged.push_str(reference_path); + merged +} + +impl Address { + /// Parses an absolute `http` or `https` address. + /// + /// This checks the syntax only; [`Address::parse_secure`] also applies the transport rule. A fragment is + /// accepted and dropped (RFC 9110 §7.1). + pub fn parse(input: &str) -> Result { + refuse_unusable_characters(input)?; + let parts = reference_parts(input)?; + let scheme_text = parts.scheme.ok_or(AddressError::MissingScheme)?; + if !looks_like_scheme(scheme_text) { + return Err(AddressError::MissingScheme); + } + let scheme = match scheme_text.to_ascii_lowercase().as_str() { + "http" => Scheme::Http, + "https" => Scheme::Https, + _ => return Err(AddressError::UnsupportedScheme), + }; + let authority = parts.authority.ok_or(AddressError::MissingAuthority)?; + if authority.contains('@') { + return Err(AddressError::UserInfo); + } + let (host_text, port_text) = separate_port(authority)?; + let host = Host::parse(host_text)?; + let port = port_number(port_text, scheme)?; + if !chars_allowed(parts.path, false) { + return Err(AddressError::InvalidPath); + } + let path = percent::normalise_escapes(parts.path).ok_or(AddressError::InvalidPath)?; + let mut path = collapse_dot_segments(&path); + if path.is_empty() { + path.push('/'); + } + let query = match parts.query { + Some(query) => { + if !chars_allowed(query, true) { + return Err(AddressError::InvalidQuery); + } + Some(percent::normalise_escapes(query).ok_or(AddressError::InvalidQuery)?) + } + None => None, + }; + Self::assemble(scheme, host, port, &path, query.as_deref()) + } + + /// Parses an absolute address and applies the transport rule ([`Address::require_secure_transport`]). + pub fn parse_secure(input: &str) -> Result { + let address = Self::parse(input)?; + address.require_secure_transport()?; + Ok(address) + } + + fn assemble(scheme: Scheme, host: Host, port: Option, path: &str, query: Option<&str>) -> Result { + let origin = Origin { scheme, host, port }; + let mut text = origin.to_string(); + let path_start = text.len(); + text.push_str(path); + let query_start = match query { + Some(query) => { + let start = text.len(); + text.push('?'); + text.push_str(query); + Some(start) + } + None => None, + }; + if text.len() > MAX_ADDRESS_LEN { + return Err(AddressError::TooLong); + } + let Origin { scheme, host, port } = origin; + Ok(Self { text, scheme, host, port, path_start, query_start }) + } + + /// Resolves a reference, relative or absolute, against this address (RFC 3986 §5.2.2, strict). + /// + /// The result is checked like [`Address::parse`]; the transport rule is not applied. + pub fn resolve(&self, reference: &str) -> Result { + if reference.is_empty() { + return Ok(self.clone()); + } + refuse_unusable_characters(reference)?; + let parts = reference_parts(reference)?; + if parts.scheme.is_some() { + return Self::parse(reference); + } + let without_fragment = reference.split_once('#').map_or(reference, |(head, _)| head); + if parts.authority.is_some() { + return Self::parse(&format!("{}:{}", self.scheme.as_str(), without_fragment)); + } + let (path, query) = if parts.path.is_empty() { + (self.path().to_owned(), parts.query.or(self.query())) + } else if parts.path.starts_with('/') { + (parts.path.to_owned(), parts.query) + } else { + (base_directory_plus(self.path(), parts.path), parts.query) + }; + // Dot-segment removal (§5.2.2, §5.2.4) runs in `parse`, after escapes are normalised (§6.2.2.2), so that + // `%2e` segments are treated the same way whether the reference was relative or absolute. + let mut target = self.origin().to_string(); + target.push_str(&path); + if let Some(query) = query { + target.push('?'); + target.push_str(query); + } + Self::parse(&target) + } + + /// The full address, for the request line. Do not log it: use [`Address::origin`] for diagnostics. + pub fn as_str(&self) -> &str { + &self.text + } + + /// The scheme. + pub fn scheme(&self) -> Scheme { + self.scheme + } + + /// The host. + pub fn host(&self) -> &Host { + &self.host + } + + /// The port, the scheme's default when the address names none. + pub fn port(&self) -> u16 { + self.port.unwrap_or_else(|| self.scheme.default_port()) + } + + /// The path, starting with `/`. + pub fn path(&self) -> &str { + let end = self.query_start.unwrap_or(self.text.len()); + self.text.get(self.path_start..end).unwrap_or("/") + } + + /// The query without its `?`, if there is one. + pub fn query(&self) -> Option<&str> { + let start = self.query_start?.checked_add(1)?; + self.text.get(start..) + } + + /// The origin: scheme, host and port. + pub fn origin(&self) -> Origin { + Origin { scheme: self.scheme, host: self.host.clone(), port: self.port } + } + + /// Whether both addresses have the same origin. + pub fn same_origin(&self, other: &Self) -> bool { + self.scheme == other.scheme && self.host == other.host && self.port == other.port + } + + /// Whether this address may carry credentials: `https`, or `http` to a loopback host + /// (see [`Host::is_loopback`]). + pub fn uses_secure_transport(&self) -> bool { + self.scheme == Scheme::Https || self.host.is_loopback() + } + + /// The transport rule as a `Result`. + pub fn require_secure_transport(&self) -> Result<(), AddressError> { + if self.uses_secure_transport() { Ok(()) } else { Err(AddressError::InsecureTransport) } + } + + /// Checks an address that `self` handed out (a session's endpoint, a template's expansion, a redirect's + /// target): it must satisfy the transport rule, and it may not be closer to this computer than `self` + /// ([`Host::scope`]), so that a remote server cannot point the program, credentials and all, at a service + /// on this computer or its local network. Only the address text is judged: a name that resolves to such an + /// address can only be caught by the program performing the request. + pub fn check_link(&self, target: &Self) -> Result<(), AddressError> { + target.require_secure_transport()?; + if target.host.scope() > self.host.scope() { + return Err(AddressError::LocalFromRemote); + } + Ok(()) + } + + /// The same origin with another path (and no query). + pub(crate) fn with_path(&self, path: &str) -> Result { + let mut text = self.origin().to_string(); + text.push_str(path); + Self::parse(&text) + } +} + +impl fmt::Debug for Address { + /// Shows the origin only (requirement 1.8): a path or query may hold something private. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Address({})", self.origin()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/jmap/src/address/tests.rs b/crates/jmap/src/address/tests.rs new file mode 100644 index 000000000..392f64641 --- /dev/null +++ b/crates/jmap/src/address/tests.rs @@ -0,0 +1,243 @@ +use super::*; + +fn parsed(input: &str) -> String { + Address::parse(input).unwrap().as_str().to_owned() +} + +// RFC 3986 §3 and RFC 9110 §4.2.1–§4.2.2: the components of http and https addresses. +#[test] +fn parses_components() { + let address = Address::parse("https://jmap.example.com:8443/api/v1?x=1&y=2").unwrap(); + assert_eq!(address.scheme(), Scheme::Https); + assert_eq!(address.host(), &Host::Name("jmap.example.com".into())); + assert_eq!(address.port(), 8443); + assert_eq!(address.path(), "/api/v1"); + assert_eq!(address.query(), Some("x=1&y=2")); + assert_eq!(address.as_str(), "https://jmap.example.com:8443/api/v1?x=1&y=2"); +} + +// RFC 3986 §6.2.2 and §6.2.3, RFC 9110 §4.2.3: the three equivalent forms normalise to one. +#[test] +fn normal_form() { + assert_eq!(parsed("http://example.com:80/~smith/home.html"), "http://example.com/~smith/home.html"); + assert_eq!(parsed("http://EXAMPLE.com/%7Esmith/home.html"), "http://example.com/~smith/home.html"); + assert_eq!(parsed("http://EXAMPLE.com:/%7esmith/home.html"), "http://example.com/~smith/home.html"); + assert_eq!(parsed("HTTPS://Example.ORG"), "https://example.org/"); + assert_eq!(parsed("https://example.org:443/a/./b/../c"), "https://example.org/a/c"); + assert_eq!(parsed("https://example.org/a%2fb?q=%2f%41"), "https://example.org/a%2Fb?q=%2FA"); + assert_eq!(parsed("https://example.org/a/%2e%2E/b"), "https://example.org/b"); +} + +// RFC 9110 §7.1: the fragment is not part of the target. +#[test] +fn drops_fragment() { + assert_eq!(parsed("https://example.com/a?b#c"), "https://example.com/a?b"); + assert_eq!(Address::parse("https://example.com/#a b"), Err(AddressError::ForbiddenCharacter)); + assert_eq!(Address::parse("https://example.com/#a#b"), Err(AddressError::InvalidFragment)); +} + +// Requirement 1.1: what is not a usable absolute http/https address is refused. +#[test] +fn refuses_unusable_addresses() { + let cases: &[(&str, AddressError)] = &[ + ("", AddressError::Empty), + ("https://exa mple.com/", AddressError::ForbiddenCharacter), + ("https://example.com/\u{7f}", AddressError::ForbiddenCharacter), + ("https://example.com/\n", AddressError::ForbiddenCharacter), + ("https://example.com/é", AddressError::ForbiddenCharacter), + ("https://example.com/{x}", AddressError::ForbiddenCharacter), + ("https://example.com\\@example.org/", AddressError::Backslash), + ("https:\\\\example.com/", AddressError::Backslash), + ("//example.com/", AddressError::MissingScheme), + ("example.com/path", AddressError::MissingScheme), + ("ftp://example.com/", AddressError::UnsupportedScheme), + ("javascript:alert(1)", AddressError::UnsupportedScheme), + ("https:example.com", AddressError::MissingAuthority), + ("https:///path", AddressError::EmptyHost), + ("https://:443/", AddressError::EmptyHost), + ("https://user@example.com/", AddressError::UserInfo), + ("https://user:secret@example.com/", AddressError::UserInfo), + ("https://[::1/", AddressError::InvalidIpv6), + ("https://[::g]/", AddressError::InvalidIpv6), + ("https://[::1]x/", AddressError::InvalidHost), + ("https://example.com:65536/", AddressError::InvalidPort), + ("https://example.com:0/", AddressError::InvalidPort), + ("https://example.com:-1/", AddressError::InvalidPort), + ("https://example.com:12a/", AddressError::InvalidPort), + ("https://example.com:1:2/", AddressError::InvalidPort), + ("https://example.com/%zz", AddressError::InvalidPath), + ("https://example.com/[x]", AddressError::InvalidPath), + ("https://example.com/?%", AddressError::InvalidQuery), + ("https://127.1/", AddressError::AmbiguousNumericHost), + ]; + for (input, expected) in cases { + assert_eq!(Address::parse(input).as_ref().map(Address::as_str), Err(expected), "{input:?}"); + } + let long = format!("https://example.com/{}", "a".repeat(MAX_ADDRESS_LEN)); + assert_eq!(Address::parse(&long), Err(AddressError::TooLong)); +} + +// RFC 3986 §3.2.3: an empty port means the default one; leading zeros are still the same number. +#[test] +fn ports() { + assert_eq!(parsed("https://example.com:/"), "https://example.com/"); + assert_eq!(parsed("https://example.com:00443/"), "https://example.com/"); + assert_eq!(parsed("http://localhost:8080"), "http://localhost:8080/"); + assert_eq!(Address::parse("https://example.com:65535/").unwrap().port(), 65535); + assert_eq!(parsed("https://[::1]:8443/x"), "https://[::1]:8443/x"); +} + +// Requirement 1.2: https always; http only to localhost, 127.0.0.0/8 or [::1]. +#[test] +fn transport_rule() { + for ok in ["https://example.com/", "http://localhost/", "http://LocalHost:8080/", "http://127.0.0.1/", "http://127.9.8.7:1/", "http://[::1]/"] { + assert!(Address::parse_secure(ok).is_ok(), "{ok}"); + } + for refused in [ + "http://example.com/", + "http://sub.localhost/", + "http://localhost./", + "http://192.0.2.1/", + "http://[2001:db8::2]/", + "http://[::ffff:127.0.0.1]/", + ] { + assert_eq!(Address::parse_secure(refused), Err(AddressError::InsecureTransport), "{refused}"); + } +} + +// Requirement 1.2 hardened, RFC 9110 §4.2.1 and RFC 8620 §8.3: a remote host cannot hand out a loopback address. +#[test] +fn links_from_remote_to_loopback_are_refused() { + let remote = Address::parse("https://jmap.example.com/").unwrap(); + let local = Address::parse("http://127.0.0.1:8080/").unwrap(); + let other = Address::parse("https://api.example.net/").unwrap(); + let plain = Address::parse("http://example.org/").unwrap(); + assert_eq!(remote.check_link(&local), Err(AddressError::LocalFromRemote)); + for sneaky in [ + "https://localhost./", + "https://x.localhost/", + "https://0.0.0.0/", + "https://[::]/", + "https://[::ffff:7f00:1]/", + "https://192.168.1.10/", + "https://[fe80::1]/", + ] { + let target = Address::parse(sneaky).unwrap(); + assert_eq!(remote.check_link(&target), Err(AddressError::LocalFromRemote), "{sneaky}"); + } + let lan = Address::parse("https://192.168.1.10/jmap").unwrap(); + assert_eq!(lan.check_link(&Address::parse("https://192.168.1.11/api").unwrap()), Ok(())); + assert_eq!(lan.check_link(&Address::parse("https://localhost/api").unwrap()), Err(AddressError::LocalFromRemote)); + assert_eq!(remote.check_link(&plain), Err(AddressError::InsecureTransport)); + assert_eq!(remote.check_link(&other), Ok(())); + assert_eq!(local.check_link(&local), Ok(())); + assert_eq!(local.check_link(&remote), Ok(())); +} + +// RFC 3986 §5.4.1: the normal examples, hosts moved under example domains (the non-http "g:h" is +// refused, as this crate only knows http and https). +#[test] +fn resolves_normal_examples() { + let base = Address::parse("http://a.example.com/b/c/d;p?q").unwrap(); + let cases = [ + ("g", "http://a.example.com/b/c/g"), + ("./g", "http://a.example.com/b/c/g"), + ("g/", "http://a.example.com/b/c/g/"), + ("/g", "http://a.example.com/g"), + ("//g.example.org", "http://g.example.org/"), + ("?y", "http://a.example.com/b/c/d;p?y"), + ("g?y", "http://a.example.com/b/c/g?y"), + ("#s", "http://a.example.com/b/c/d;p?q"), + ("g#s", "http://a.example.com/b/c/g"), + ("g?y#s", "http://a.example.com/b/c/g?y"), + (";x", "http://a.example.com/b/c/;x"), + ("g;x", "http://a.example.com/b/c/g;x"), + ("g;x?y#s", "http://a.example.com/b/c/g;x?y"), + ("", "http://a.example.com/b/c/d;p?q"), + (".", "http://a.example.com/b/c/"), + ("./", "http://a.example.com/b/c/"), + ("..", "http://a.example.com/b/"), + ("../", "http://a.example.com/b/"), + ("../g", "http://a.example.com/b/g"), + ("../..", "http://a.example.com/"), + ("../../", "http://a.example.com/"), + ("../../g", "http://a.example.com/g"), + ]; + for (reference, expected) in cases { + assert_eq!(base.resolve(reference).unwrap().as_str(), expected, "{reference:?}"); + } + assert_eq!(base.resolve("g:h"), Err(AddressError::UnsupportedScheme)); +} + +// RFC 3986 §5.4.2: the abnormal examples (hosts under example domains), strict for "http:g". +#[test] +fn resolves_abnormal_examples() { + let base = Address::parse("http://a.example.com/b/c/d;p?q").unwrap(); + let cases = [ + ("../../../g", "http://a.example.com/g"), + ("../../../../g", "http://a.example.com/g"), + ("/./g", "http://a.example.com/g"), + ("/../g", "http://a.example.com/g"), + ("g.", "http://a.example.com/b/c/g."), + (".g", "http://a.example.com/b/c/.g"), + ("g..", "http://a.example.com/b/c/g.."), + ("..g", "http://a.example.com/b/c/..g"), + ("./../g", "http://a.example.com/b/g"), + ("./g/.", "http://a.example.com/b/c/g/"), + ("g/./h", "http://a.example.com/b/c/g/h"), + ("g/../h", "http://a.example.com/b/c/h"), + ("g;x=1/./y", "http://a.example.com/b/c/g;x=1/y"), + ("g;x=1/../y", "http://a.example.com/b/c/y"), + ("g?y/./x", "http://a.example.com/b/c/g?y/./x"), + ("g?y/../x", "http://a.example.com/b/c/g?y/../x"), + ("g#s/./x", "http://a.example.com/b/c/g"), + ("g#s/../x", "http://a.example.com/b/c/g"), + ]; + for (reference, expected) in cases { + assert_eq!(base.resolve(reference).unwrap().as_str(), expected, "{reference:?}"); + } + assert_eq!(base.resolve("http:g"), Err(AddressError::MissingAuthority)); + // RFC 3986 §6.2.2.2: an escaped dot is a dot, whether the reference is relative or absolute. + assert_eq!(base.resolve("/a/%2e%2e/../b"), Address::parse("http://a.example.com/a/%2e%2e/../b")); + assert_eq!(base.resolve("/a/%2e%2e/../b").unwrap().path(), "/b"); +} + +// RFC 3986 §5.2.4: the two worked examples of dot-segment removal. +#[test] +fn removes_dot_segments() { + assert_eq!(collapse_dot_segments("/a/b/c/./../../g"), "/a/g"); + assert_eq!(collapse_dot_segments("mid/content=5/../6"), "mid/6"); + assert_eq!(collapse_dot_segments("/.."), "/"); + assert_eq!(collapse_dot_segments("//x/../y"), "//y"); +} + +// Requirement 1.4: the origin elides the default port; the full form keeps everything else. +#[test] +fn origin_and_full_form() { + let address = Address::parse("https://example.com:443/session?token=abc").unwrap(); + assert_eq!(address.origin().to_string(), "https://example.com"); + assert_eq!(address.origin().port(), 443); + let address = Address::parse("http://[::1]:8080/x").unwrap(); + assert_eq!(address.origin().to_string(), "http://[::1]:8080"); + assert!(address.same_origin(&Address::parse("http://[0::1]:8080/y").unwrap())); + assert!(!address.same_origin(&Address::parse("http://[::1]:8081/x").unwrap())); +} + +// Requirement 1.8: diagnostics never show more than the origin. +#[test] +fn debug_shows_origin_only() { + let address = Address::parse("https://example.com/private/path?token=s3cr3t").unwrap(); + let shown = format!("{address:?}"); + assert_eq!(shown, "Address(https://example.com)"); + assert!(!shown.contains("s3cr3t") && !shown.contains("private")); +} + +// RFC 3986 §6.2.2: normalisation is idempotent, so parsing the full form gives the same address. +#[test] +fn reparse_is_stable() { + for input in ["https://EXAMPLE.com:443/a/../b/%7e?Q=%2f", "http://[0:0::1]:80//x/./y", "https://example.org"] { + let first = Address::parse(input).unwrap(); + let second = Address::parse(first.as_str()).unwrap(); + assert_eq!(first, second); + } +} diff --git a/crates/jmap/src/classify.rs b/crates/jmap/src/classify.rs new file mode 100644 index 000000000..ad674e59e --- /dev/null +++ b/crates/jmap/src/classify.rs @@ -0,0 +1,199 @@ +//! Judging a response: status codes (RFC 9110 §15), problem details (RFC 9457, which obsoletes RFC 7807 and +//! which JMAP's request-level errors use, RFC 8620 §3.6.1), content types (RFC 9110 §8.3) and JSON (RFC 8259). + +use serde_json::{Map, Value}; + +use crate::error::{Error, LimitName, RequestProblem, Unusable}; +use crate::http::{HttpRequest, HttpResponse, PROBLEM_BODY_LIMIT}; +use crate::redact::{Excerpt, Redactor}; +use crate::retry::RetryAfter; + +/// The URN prefix of JMAP's request-level problem types (RFC 8620 §3.6.1, §9.3). +const JMAP_PROBLEM_PREFIX: &str = "urn:ietf:params:jmap:error:"; +/// The longest JMAP problem name kept. +const MAX_PROBLEM_NAME_LEN: usize = 64; + +/// Whether a `Content-Type` value names `expected` (RFC 9110 §8.3.1: type and subtype are case-insensitive; +/// parameters such as `charset` are ignored, JSON being UTF-8 by RFC 8259 §8.1). +pub(crate) fn media_type_is(content_type: Option<&str>, expected: &str) -> bool { + content_type.is_some_and(|value| { + let essence = value.split(';').next().unwrap_or("").trim_matches([' ', '\t']); + essence.eq_ignore_ascii_case(expected) + }) +} + +/// The most JSON values a body may hold before it is parsed into memory. +pub const MAX_JSON_VALUES: usize = 1_000_000; +/// The most JSON objects a body may hold before it is parsed into memory. +pub const MAX_JSON_OBJECTS: usize = 100_000; + +/// Counts, without building anything, an upper bound of the values and the objects of a JSON text, so that a +/// small text cannot expand into a huge tree (RFC 8259 §9 lets a parser limit what it accepts; RFC 8620 §8.4). +/// Every value but the first follows `[`, `,` or `:`; strings are skipped with their escapes. +fn within_budget(body: &[u8]) -> bool { + let mut in_string = false; + let mut escaped = false; + let mut values = 1usize; + let mut objects = 0usize; + for &b in body { + if in_string { + if escaped { + escaped = false; + } else if b == b'\\' { + escaped = true; + } else if b == b'"' { + in_string = false; + } + continue; + } + match b { + b'"' => in_string = true, + b'{' => objects = objects.saturating_add(1), + b'[' | b',' | b':' => values = values.saturating_add(1), + _ => {} + } + if values > MAX_JSON_VALUES || objects > MAX_JSON_OBJECTS { + return false; + } + } + true +} + +/// Parses a JSON text (RFC 8259), skipping a leading byte order mark (RFC 8259 §8.1 allows ignoring one). +/// Nesting is bounded by `serde_json`'s recursion limit (128); the number of values and objects by +/// [`MAX_JSON_VALUES`] and [`MAX_JSON_OBJECTS`]. +pub(crate) fn parse_json(body: &[u8]) -> Result { + let body = body.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(body); + if !within_budget(body) { + return Err(Unusable::TooComplex); + } + serde_json::from_slice(body).map_err(|_| Unusable::InvalidJson) +} + +/// What was understood of a problem details object. +#[derive(Debug, Default)] +struct Problem { + jmap: Option, + status: Option, + limit: Option, + text: Option, + names_bad_token: bool, +} + +fn jmap_problem(kind: &str) -> Option { + let prefix = kind.get(..JMAP_PROBLEM_PREFIX.len())?; + if !prefix.eq_ignore_ascii_case(JMAP_PROBLEM_PREFIX) { + return None; + } + let name = kind.get(JMAP_PROBLEM_PREFIX.len()..)?; + Some(match name { + "unknownCapability" => RequestProblem::UnknownCapability, + "notJSON" => RequestProblem::NotJson, + "notRequest" => RequestProblem::NotRequest, + "limit" => RequestProblem::Limit, + other if !other.is_empty() && other.len() <= MAX_PROBLEM_NAME_LEN && other.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') => { + RequestProblem::Other(other.to_owned()) + } + _ => return None, + }) +} + +/// Reads a problem details object (RFC 9457 §3.1). Members of the wrong type are ignored, as §3.1 requires. +fn read_problem(response: &HttpResponse, limit: usize) -> Option { + let body = response.body(); + if body.is_empty() || body.len() > PROBLEM_BODY_LIMIT.min(limit) { + return None; + } + let content_type = response.content_type(); + if content_type.is_some() && !media_type_is(content_type, "application/problem+json") && !media_type_is(content_type, "application/json") { + return None; + } + let value = parse_json(body).ok()?; + let object: &Map = value.as_object()?; + let mut problem = Problem::default(); + if let Some(kind) = object.get("type").and_then(Value::as_str) { + problem.jmap = jmap_problem(kind); + let last = kind.rsplit([':', '/', '#']).next().unwrap_or(""); + problem.names_bad_token = last.eq_ignore_ascii_case("invalid_token") || last.eq_ignore_ascii_case("insufficient_scope"); + } + problem.status = object.get("status").and_then(Value::as_u64).and_then(|status| u16::try_from(status).ok()); + problem.limit = object.get("limit").and_then(Value::as_str).and_then(LimitName::new); + problem.text = object.get("detail").and_then(Value::as_str).or_else(|| object.get("title").and_then(Value::as_str)).map(str::to_owned); + Some(problem) +} + +fn excerpt(problem: Option<&Problem>, redactor: &Redactor) -> Option { + let text = problem?.text.as_deref()?; + let excerpt = redactor.excerpt(text); + if excerpt.as_str().is_empty() { None } else { Some(excerpt) } +} + +/// Judges a response to `request`. +/// +/// `Ok(())` means the body may be read as the request's kind expects (for session and API requests: a 2xx +/// status, `application/json`, and a body within the size limit). Everything else becomes the [`Error`] +/// variant the mail client can act on. Server text appears only as an [`Excerpt`] cleaned by `redactor`. +pub fn judge_response(request: &HttpRequest, response: &HttpResponse, redactor: &Redactor) -> Result<(), Error> { + let status = response.status(); + if (200..=299).contains(&status) { + if response.body().len() > request.max_response_bytes() { + return Err(Error::Unusable(Unusable::BodyTooLarge)); + } + if request.kind().expects_json() && !media_type_is(response.content_type(), "application/json") { + return Err(Error::Unusable(Unusable::WrongContentType)); + } + return Ok(()); + } + if (300..=399).contains(&status) { + return Err(judge_redirect(request, response)); + } + let problem = read_problem(response, request.max_response_bytes()); + let detail = excerpt(problem.as_ref(), redactor); + let retry_after = || response.retry_after().and_then(RetryAfter::parse); + // A server-chosen identifier is shown in messages: drop it when it echoes a secret. + let jmap = problem.as_ref().and_then(|problem| problem.jmap.clone()).map(|jmap| match jmap { + RequestProblem::Other(name) if redactor.mentions_secret(&name) => RequestProblem::Other(String::new()), + other => other, + }); + let limit = problem.as_ref().and_then(|problem| problem.limit.clone()).filter(|limit| !redactor.mentions_secret(limit.as_str())); + // RFC 9457 §3.1.2: the status member tells what the origin server meant when an intermediary changed it. + let says_unauthorised = problem.as_ref().is_some_and(|problem| problem.status == Some(401) || problem.names_bad_token); + Err(match status { + 401 => Error::CredentialsRefused, + 403 if says_unauthorised => Error::CredentialsRefused, + 429 | 503 => Error::Busy { retry_after: retry_after() }, + 413 => Error::TooLarge { limit }, + _ if jmap == Some(RequestProblem::Limit) => match limit { + Some(limit) if limit.is_concurrency() => Error::Busy { retry_after: retry_after() }, + limit => Error::TooLarge { limit }, + }, + 403 => Error::Forbidden { detail }, + 404 | 410 => Error::NotFound, + 400..=499 if jmap.is_some() => Error::Refused { problem: jmap, detail }, + 400 | 405 | 406 | 415 | 422 => Error::Refused { problem: None, detail }, + _ => Error::Http { status, detail }, + }) +} + +/// A redirect (RFC 9110 §15.4) is never followed here: its target is checked and handed to the caller. +fn judge_redirect(request: &HttpRequest, response: &HttpResponse) -> Error { + let status = response.status(); + let Some(location) = response.location() else { + return Error::Http { status, detail: None }; + }; + if !matches!(status, 301 | 302 | 303 | 307 | 308) { + return Error::Http { status, detail: None }; + } + let from = request.address(); + let target = match from.resolve(location) { + Ok(target) => target, + Err(error) => return Error::RedirectRefused(error), + }; + if let Err(error) = from.check_link(&target) { + return Error::RedirectRefused(error); + } + Error::Redirected { same_origin: from.same_origin(&target), target } +} + +#[cfg(test)] +mod tests; diff --git a/crates/jmap/src/classify/tests.rs b/crates/jmap/src/classify/tests.rs new file mode 100644 index 000000000..9a179bc0b --- /dev/null +++ b/crates/jmap/src/classify/tests.rs @@ -0,0 +1,199 @@ +use super::*; +use crate::address::{Address, AddressError}; +use crate::credentials::Credentials; +use crate::http::{API_RESPONSE_LIMIT, HttpRequest, Method, RequestKind}; +use std::time::Duration; + +fn request(kind: RequestKind) -> HttpRequest { + let credentials = Credentials::bearer("token-abcdef").unwrap(); + let address = Address::parse("https://jmap.example.com/api/").unwrap(); + HttpRequest::new(Method::Post, address, kind, credentials.authorization()) +} + +fn judge(response: HttpResponse) -> Result<(), Error> { + let mut redactor = Redactor::new(); + redactor.add("token-abcdef"); + judge_response(&request(RequestKind::Api), &response, &redactor) +} + +fn problem(status: u16, body: &str) -> HttpResponse { + HttpResponse::new(status, body.as_bytes().to_vec()).with_field("Content-Type", "application/problem+json") +} + +// RFC 8620 §3.1: a successful API response is application/json. +#[test] +fn success_needs_json() { + let ok = HttpResponse::new(200, b"{}".to_vec()).with_field("Content-Type", "Application/JSON; charset=utf-8"); + assert_eq!(judge(ok), Ok(())); + let html = HttpResponse::new(200, b"".to_vec()).with_field("Content-Type", "text/html"); + assert_eq!(judge(html), Err(Error::Unusable(Unusable::WrongContentType))); + assert_eq!(judge(HttpResponse::new(200, b"{}".to_vec())), Err(Error::Unusable(Unusable::WrongContentType))); + let download = HttpResponse::new(200, vec![1, 2, 3]).with_field("Content-Type", "application/pdf"); + assert_eq!(judge_response(&request(RequestKind::Download), &download, &Redactor::new()), Ok(())); +} + +// Requirement 2.2: a limit the caller set for one request is enforced too, at the octet. +#[test] +fn per_request_body_limit() { + let limited = request(RequestKind::Download).with_response_limit(10); + let exact = HttpResponse::new(200, vec![0; 10]); + assert_eq!(judge_response(&limited, &exact, &Redactor::new()), Ok(())); + let over = HttpResponse::new(200, vec![0; 11]); + assert_eq!(judge_response(&limited, &over, &Redactor::new()), Err(Error::Unusable(Unusable::BodyTooLarge))); +} + +// Requirement 2.2: the crate refuses a body above the limit it states for the kind of request. +#[test] +fn success_body_limit() { + let big = HttpResponse::new(200, vec![b' '; API_RESPONSE_LIMIT + 1]).with_field("Content-Type", "application/json"); + assert_eq!(judge(big), Err(Error::Unusable(Unusable::BodyTooLarge))); +} + +// RFC 9110 §15.5.2 and §15.5.4, RFC 9457 §3.1.2: 401, and 403 when the problem says so. +#[test] +fn credentials_refused() { + assert_eq!(judge(HttpResponse::new(401, Vec::new())), Err(Error::CredentialsRefused)); + assert_eq!(judge(problem(403, r#"{"status":401,"title":"Unauthorized"}"#)), Err(Error::CredentialsRefused)); + assert_eq!(judge(problem(403, r#"{"type":"https://example.com/probs/invalid_token"}"#)), Err(Error::CredentialsRefused)); + assert!(matches!(judge(problem(403, r#"{"status":403,"detail":"no access to this account"}"#)), Err(Error::Forbidden { detail: Some(_) }))); + assert!(matches!(judge(HttpResponse::new(403, Vec::new())), Err(Error::Forbidden { detail: None }))); +} + +// Requirement 2.4 (429 and 503), RFC 9110 §15.6.4 and §10.2.3: busy, with the delay asked for. +#[test] +fn busy() { + let response = HttpResponse::new(503, Vec::new()).with_field("Retry-After", "120"); + assert_eq!(judge(response), Err(Error::Busy { retry_after: Some(RetryAfter::Delay(Duration::from_secs(120))) })); + let response = HttpResponse::new(429, Vec::new()).with_field("Retry-After", "soon"); + assert_eq!(judge(response), Err(Error::Busy { retry_after: None })); + let concurrency = problem(400, r#"{"type":"urn:ietf:params:jmap:error:limit","limit":"maxConcurrentRequests","status":400}"#); + assert_eq!(judge(concurrency), Err(Error::Busy { retry_after: None })); +} + +// RFC 9110 §15.5.14 and RFC 8620 §3.6.1: 413 and the JMAP limit problem with its limit name. +#[test] +fn too_large() { + assert_eq!(judge(HttpResponse::new(413, Vec::new())), Err(Error::TooLarge { limit: None })); + let limit = problem(400, r#"{"type":"urn:ietf:params:jmap:error:limit","limit":"maxSizeRequest","status":400,"detail":"too big"}"#); + assert_eq!(judge(limit), Err(Error::TooLarge { limit: LimitName::new("maxSizeRequest") })); + let odd = problem(400, r#"{"type":"urn:ietf:params:jmap:error:limit","limit":"max size\n"}"#); + assert_eq!(judge(odd), Err(Error::TooLarge { limit: None })); +} + +// RFC 8620 §3.6.1: the other request-level problems are recognised; RFC 9110 §15.5.5 and §15.5.11. +#[test] +fn refused_and_not_found() { + let unknown = problem( + 400, + r#"{"type":"urn:ietf:params:jmap:error:unknownCapability","status":400,"detail":"The Request object used capability 'https://example.com/apis/foobar', which is not supported by this server."}"#, + ); + match judge(unknown) { + Err(Error::Refused { problem: Some(RequestProblem::UnknownCapability), detail: Some(detail) }) => { + assert!(detail.as_str().starts_with("The Request object used capability")); + } + other => panic!("{other:?}"), + } + assert!(matches!( + judge(problem(400, r#"{"type":"urn:ietf:params:jmap:error:notJSON"}"#)), + Err(Error::Refused { problem: Some(RequestProblem::NotJson), .. }) + )); + assert!(matches!( + judge(problem(400, r#"{"type":"urn:ietf:params:jmap:error:notRequest"}"#)), + Err(Error::Refused { problem: Some(RequestProblem::NotRequest), .. }) + )); + assert!(matches!( + judge(problem(400, r#"{"type":"URN:IETF:params:jmap:error:somethingNew"}"#)), + Err(Error::Refused { problem: Some(RequestProblem::Other(_)), .. }) + )); + assert!(matches!(judge(HttpResponse::new(400, b"not json".to_vec())), Err(Error::Refused { problem: None, detail: None }))); + assert_eq!(judge(HttpResponse::new(404, Vec::new())), Err(Error::NotFound)); + assert_eq!(judge(HttpResponse::new(410, Vec::new())), Err(Error::NotFound)); +} + +// Requirement 3.5: a server-chosen identifier that echoes a secret is not shown. +#[test] +fn identifiers_echoing_secrets_are_dropped() { + let mut redactor = Redactor::new(); + redactor.add("hunter22"); + let judge = |body: &str| judge_response(&request(RequestKind::Api), &problem(400, body), &redactor); + assert_eq!( + judge(r#"{"type":"urn:ietf:params:jmap:error:hunter22"}"#), + Err(Error::Refused { problem: Some(RequestProblem::Other(String::new())), detail: None }) + ); + assert_eq!(judge(r#"{"type":"urn:ietf:params:jmap:error:limit","limit":"xhunter22"}"#), Err(Error::TooLarge { limit: None })); +} + +// RFC 9457 §3.1: a member of the wrong type is ignored. +#[test] +fn wrong_member_types_are_ignored() { + let response = problem(400, r#"{"type":42,"status":"401","limit":[],"detail":{"x":1},"title":"Bad"}"#); + match judge(response) { + Err(Error::Refused { problem: None, detail: Some(detail) }) => assert_eq!(detail.as_str(), "Bad"), + other => panic!("{other:?}"), + } +} + +// Requirement 2.4: server text is a short, clean excerpt without secrets. +#[test] +fn detail_is_cleaned() { + let body = format!(r#"{{"detail":"bad token token-abcdef\n\u001b[2J{}"}}"#, "x".repeat(500)); + match judge(problem(500, &body)) { + Err(Error::Http { status: 500, detail: Some(detail) }) => { + assert!(detail.as_str().starts_with("bad token [redacted] [2J")); + assert!(!detail.as_str().contains('\n') && !detail.as_str().contains('\u{1b}')); + assert!(detail.as_str().chars().count() <= crate::redact::EXCERPT_MAX_CHARS + 1); + } + other => panic!("{other:?}"), + } + let html = HttpResponse::new(502, b"

Bad gateway

".to_vec()).with_field("Content-Type", "text/html"); + assert_eq!(judge(html), Err(Error::Http { status: 502, detail: None })); +} + +// RFC 9110 §10.2.2 and §15.4: redirects are judged, not followed; http and loopback targets are refused. +#[test] +fn redirects() { + let response = HttpResponse::new(307, Vec::new()).with_field("Location", "/api/v2/"); + match judge(response) { + Err(Error::Redirected { target, same_origin: true }) => assert_eq!(target.as_str(), "https://jmap.example.com/api/v2/"), + other => panic!("{other:?}"), + } + let response = HttpResponse::new(301, Vec::new()).with_field("Location", "https://jmap.example.net/api/"); + assert!(matches!(judge(response), Err(Error::Redirected { same_origin: false, .. }))); + let response = HttpResponse::new(302, Vec::new()).with_field("Location", "http://jmap.example.com/api/"); + assert_eq!(judge(response), Err(Error::RedirectRefused(AddressError::InsecureTransport))); + let response = HttpResponse::new(308, Vec::new()).with_field("Location", "http://127.0.0.1:8080/"); + assert_eq!(judge(response), Err(Error::RedirectRefused(AddressError::LocalFromRemote))); + let response = HttpResponse::new(302, Vec::new()).with_field("Location", "https://user@example.com/"); + assert_eq!(judge(response), Err(Error::RedirectRefused(AddressError::UserInfo))); + assert_eq!(judge(HttpResponse::new(302, Vec::new())), Err(Error::Http { status: 302, detail: None })); + assert_eq!(judge(HttpResponse::new(304, Vec::new())), Err(Error::Http { status: 304, detail: None })); +} + +// RFC 9110 §15: anything else is an HTTP error with its status. +#[test] +fn other_statuses() { + assert_eq!(judge(HttpResponse::new(500, Vec::new())), Err(Error::Http { status: 500, detail: None })); + assert_eq!(judge(HttpResponse::new(418, Vec::new())), Err(Error::Http { status: 418, detail: None })); + assert_eq!(judge(HttpResponse::new(100, Vec::new())), Err(Error::Http { status: 100, detail: None })); + assert_eq!(judge(HttpResponse::new(0, Vec::new())), Err(Error::Http { status: 0, detail: None })); +} + +// RFC 8259 §8.1: a byte order mark may be ignored. +#[test] +fn json_with_bom() { + assert!(parse_json(b"\xEF\xBB\xBF{\"a\":1}").is_ok()); + assert_eq!(parse_json(b"{\"a\":"), Err(Unusable::InvalidJson)); + let deep = "[".repeat(10_000); + assert_eq!(parse_json(deep.as_bytes()), Err(Unusable::InvalidJson)); +} + +// RFC 8259 §9 and RFC 8620 §8.4: a text that would expand into too many values is refused before parsing. +#[test] +fn json_budget() { + let many_objects = format!("[{}{{}}]", "{},".repeat(MAX_JSON_OBJECTS)); + assert_eq!(parse_json(many_objects.as_bytes()), Err(Unusable::TooComplex)); + let many_values = format!("[{}0]", "0,".repeat(MAX_JSON_VALUES)); + assert_eq!(parse_json(many_values.as_bytes()), Err(Unusable::TooComplex)); + let in_strings = format!("[\"{}\"]", "{},:[\\\"".repeat(200_000)); + assert!(parse_json(in_strings.as_bytes()).is_ok()); +} diff --git a/crates/jmap/src/client.rs b/crates/jmap/src/client.rs new file mode 100644 index 000000000..43ee983af --- /dev/null +++ b/crates/jmap/src/client.rs @@ -0,0 +1,245 @@ +//! Fetching the session and talking to the API through the program's HTTP exchange. +//! +//! Each operation exists in two forms: a synchronous pair (build the [`HttpRequest`], then read the +//! [`HttpResponse`]) for programs that drive the exchange themselves, and an `async` helper that does both +//! through an [`HttpExchange`]. Neither follows redirects: a redirect comes back as +//! [`Error::Redirected`] with a checked target, and the caller decides. + +use std::fmt; + +use crate::address::Address; +use crate::classify::judge_response; +use crate::credentials::Credentials; +use crate::error::{BuildError, Error}; +use crate::http::{HttpExchange, HttpRequest, HttpResponse, Method, RequestKind}; +use crate::id::Id; +use crate::redact::{Excerpt, Redactor}; +use crate::request::ApiRequest; +use crate::response::ApiResponse; +use crate::session::{Account, CAPABILITY_MAIL, CoreLimits, Session}; + +/// A redactor knowing the credentials and the session address. +fn base_redactor(address: &Address, credentials: &Credentials) -> Redactor { + let mut redactor = Redactor::new(); + credentials.register_secrets(&mut redactor); + redactor.add_personal(address.as_str()); + if let Some(query) = address.query() { + redactor.add_personal(query); + // A query parameter may carry a token: each value, as written and decoded. + for pair in query.split('&') { + let value = pair.split_once('=').map_or(pair, |(_, value)| value); + redactor.add_personal(value); + if let Ok(decoded) = crate::percent::decode(value) { + let decoded = crate::credentials::Secret::new(decoded); + redactor.add_personal(decoded.expose()); + } + } + } + redactor +} + +/// Builds the request that fetches the session object (RFC 8620 §2). The address must satisfy the transport +/// rule, since the credentials travel with the request. +pub fn session_request(address: &Address, credentials: &Credentials) -> Result { + address.require_secure_transport()?; + Ok(HttpRequest::new(Method::Get, address.clone(), RequestKind::Session, credentials.authorization())) +} + +/// Reads the response to a [`session_request`]. +pub fn read_session(request: &HttpRequest, response: &HttpResponse, credentials: &Credentials) -> Result { + let redactor = base_redactor(request.address(), credentials); + judge_response(request, response, &redactor)?; + Session::parse(response.body(), request.address()) +} + +/// Fetches and reads the session object through `exchange`. +pub async fn fetch_session(exchange: &E, address: &Address, credentials: &Credentials) -> Result { + let request = session_request(address, credentials)?; + let response = exchange.exchange(&request).await?; + read_session(&request, &response, credentials) +} + +/// A JMAP client for one session and one mail account. +#[derive(Clone)] +pub struct Client { + session: Session, + credentials: Credentials, + account: Id, + redactor: Redactor, +} + +impl Client { + /// A client for the session's mail account ([`Session::mail_account`]). + pub fn new(session: Session, credentials: Credentials) -> Result { + let account = session.mail_account().cloned().ok_or(Error::NoMailAccount)?; + Self::for_account(session, credentials, account) + } + + /// A client for a chosen account of the session. + pub fn for_account(session: Session, credentials: Credentials, account: Id) -> Result { + let account_name = session.account(&account).ok_or(Error::NoMailAccount)?.name().to_owned(); + let mut redactor = base_redactor(session.address(), &credentials); + redactor.add_personal(session.username()); + redactor.add_personal(&account_name); + Ok(Self { session, credentials, account, redactor }) + } + + /// The session. + pub fn session(&self) -> &Session { + &self.session + } + + /// The account id in use. + pub fn account_id(&self) -> &Id { + &self.account + } + + /// The account in use. + pub fn account(&self) -> Option<&Account> { + self.session.account(&self.account) + } + + /// The core limits, defaulted and capped. + pub fn limits(&self) -> &CoreLimits { + self.session.core_limits() + } + + /// Whether the server advertises `capability`. + pub fn has_capability(&self, capability: &str) -> bool { + self.session.has_capability(capability) + } + + /// Whether the account in use offers `capability`. + pub fn account_has_capability(&self, capability: &str) -> bool { + self.account().is_some_and(|account| account.has_capability(capability)) + } + + /// Whether the account offers mail. + pub fn has_mail(&self) -> bool { + self.account_has_capability(CAPABILITY_MAIL) + } + + /// Cleans a server's text for an error message, with every secret this client knows replaced. + pub fn excerpt(&self, text: &str) -> Excerpt { + self.redactor.excerpt(text) + } + + /// The redactor holding this client's secrets. + pub fn redactor(&self) -> &Redactor { + &self.redactor + } + + /// Whether `response` reports a session state other than the one this client holds (RFC 8620 §3.4): the + /// session should then be fetched again. + pub fn is_session_stale(&self, response: &ApiResponse) -> bool { + response.session_state() != self.session.state() + } + + /// The address to download a blob (RFC 8620 §6.2), with `media_type` as `type` and `name` as `name`. + pub fn download_address(&self, blob: &Id, media_type: &str, name: &str) -> Result { + let values = [("accountId", self.account.as_str()), ("blobId", blob.as_str()), ("type", media_type), ("name", name)]; + Ok(self.session.download_template().expand(&values)?) + } + + /// The address to upload a blob to the account (RFC 8620 §6.1). + pub fn upload_address(&self) -> Result { + Ok(self.session.upload_template().expand(&[("accountId", self.account.as_str())])?) + } + + /// The request that fetches the session again. + pub fn session_request(&self) -> Result { + session_request(self.session.address(), &self.credentials) + } + + /// The HTTP request for an API request (RFC 8620 §3.1). Checked first: the capabilities used must be + /// advertised, the number of calls must be within `maxCallsInRequest`, and the body within + /// `maxSizeRequest`. + pub fn api_request(&self, request: &ApiRequest) -> Result { + if request.capabilities().any(|capability| !self.session.has_capability(capability)) { + return Err(BuildError::UnsupportedCapability.into()); + } + let calls = u64::try_from(request.len()).unwrap_or(u64::MAX); + let max_calls = self.limits().max_calls_in_request(); + if calls > max_calls { + return Err(BuildError::TooManyCalls { limit: max_calls }.into()); + } + let body = request.to_json()?; + let max_size = self.limits().max_size_request(); + if u64::try_from(body.len()).unwrap_or(u64::MAX) > max_size { + return Err(BuildError::TooLarge { limit: max_size }.into()); + } + let address = self.session.api_address().clone(); + let http = HttpRequest::new(Method::Post, address, RequestKind::Api, self.credentials.authorization()).with_json_body(body); + Ok(match request.response_limit() { + Some(limit) => http.with_response_limit(limit), + None => http, + }) + } + + /// Reads the response to an [`Client::api_request`]. + pub fn read_api_response(&self, request: &HttpRequest, response: &HttpResponse) -> Result { + judge_response(request, response, &self.redactor)?; + ApiResponse::parse(response.body(), &self.redactor) + } + + /// The HTTP request that downloads a blob (RFC 8620 §6.2). + pub fn download_request(&self, blob: &Id, media_type: &str, name: &str) -> Result { + let address = self.download_address(blob, media_type, name)?; + Ok(HttpRequest::new(Method::Get, address, RequestKind::Download, self.credentials.authorization())) + } + + /// Reads the response to a [`Client::download_request`] and hands back the blob's bytes. + pub fn read_download(&self, request: &HttpRequest, response: HttpResponse) -> Result, Error> { + judge_response(request, &response, &self.redactor)?; + Ok(response.into_body()) + } + + /// Sends an API request through `exchange` and reads the response. + pub async fn call(&self, exchange: &E, request: &ApiRequest) -> Result { + let http = self.api_request(request)?; + let response = exchange.exchange(&http).await?; + self.read_api_response(&http, &response) + } + + /// Downloads a blob through `exchange`. + /// + /// `max_bytes`, when given, lowers the largest body accepted for this download below + /// [`DOWNLOAD_RESPONSE_LIMIT`](crate::http::DOWNLOAD_RESPONSE_LIMIT); the exchange sees it before + /// reading the body. + pub async fn download( + &self, + exchange: &E, + blob: &Id, + media_type: &str, + name: &str, + max_bytes: Option, + ) -> Result, Error> { + let mut http = self.download_request(blob, media_type, name)?; + if let Some(limit) = max_bytes { + http = http.with_response_limit(limit); + } + let response = exchange.exchange(&http).await?; + self.read_download(&http, response) + } + + /// Fetches the session again through `exchange`, for when [`Client::is_session_stale`] says so. + pub async fn refetch_session(&self, exchange: &E) -> Result { + fetch_session(exchange, self.session.address(), &self.credentials).await + } +} + +impl fmt::Debug for Client { + /// Only what says nothing about the person: the authentication scheme, how many capabilities the + /// server offers, and the core limits. No account id, no address or origin (a personal domain names its + /// owner), no user name, no account name, no secret. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("scheme", &self.credentials.scheme_name()) + .field("capabilities", &self.session.capabilities().count()) + .field("limits", self.session.core_limits()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/jmap/src/client/tests.rs b/crates/jmap/src/client/tests.rs new file mode 100644 index 000000000..b398dbaf2 --- /dev/null +++ b/crates/jmap/src/client/tests.rs @@ -0,0 +1,245 @@ +use super::*; +use crate::address::AddressError; +use crate::error::Unusable; +use crate::http::TransportError; +use crate::retry::RetryAfter; +use crate::session::tests::{EXAMPLE, session_address}; +use crate::test_support::{FakeExchange, block_on, made_secret}; +use serde_json::{Map, Value, json}; +use std::time::Duration; + +fn credentials() -> Credentials { + Credentials::basic("john@example.com", &made_secret("open-sesame-42")).unwrap() +} + +fn json_response(status: u16, body: &Value) -> HttpResponse { + HttpResponse::new(status, body.to_string().into_bytes()).with_field("Content-Type", "application/json") +} + +fn client() -> Client { + let session = Session::parse(EXAMPLE.as_bytes(), &session_address()).unwrap(); + Client::new(session, credentials()).unwrap() +} + +// RFC 8620 §2: the session is fetched with an authenticated GET and parsed. +#[test] +fn fetches_session() { + let exchange = FakeExchange::new(vec![Ok(HttpResponse::new(200, EXAMPLE.as_bytes().to_vec()).with_field("Content-Type", "application/json"))]); + let session = block_on(fetch_session(&exchange, &session_address(), &credentials())).unwrap(); + assert_eq!(session.state(), "75128aab4b1b"); + let sent = exchange.sent(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].method(), Method::Get); + assert_eq!(sent[0].url(), "https://jmap.example.com/.well-known/jmap"); + assert!(sent[0].fields().any(|(name, value)| name == "Authorization" && value == "Basic am9obkBleGFtcGxlLmNvbTpvcGVuLXNlc2FtZS00Mg==")); +} + +// Requirement 1.2: credentials are never sent over plain http to a remote host. +#[test] +fn refuses_insecure_session_address() { + let exchange = FakeExchange::new(Vec::new()); + let address = Address::parse("http://jmap.example.com/").unwrap(); + assert_eq!(block_on(fetch_session(&exchange, &address, &credentials())), Err(Error::Address(AddressError::InsecureTransport))); + assert!(exchange.sent().is_empty()); +} + +// RFC 8620 §2.2 ("following any redirects") with requirement 3.1: the redirect comes back to the caller. +#[test] +fn session_redirect_is_returned() { + let exchange = FakeExchange::new(vec![Ok(HttpResponse::new(301, Vec::new()).with_field("Location", "https://mail.example.net/jmap/session"))]); + match block_on(fetch_session(&exchange, &session_address(), &credentials())) { + Err(Error::Redirected { target, same_origin: false }) => assert_eq!(target.as_str(), "https://mail.example.net/jmap/session"), + other => panic!("{other:?}"), + } + let exchange = FakeExchange::new(vec![Ok(HttpResponse::new(302, Vec::new()).with_field("Location", "http://mail.example.net/"))]); + assert_eq!(block_on(fetch_session(&exchange, &session_address(), &credentials())), Err(Error::RedirectRefused(AddressError::InsecureTransport))); +} + +// Requirement 3.2: the account, limits, capabilities, download and upload addresses. +#[test] +fn client_exposes_what_mail_code_needs() { + let client = client(); + assert_eq!(client.account_id().as_str(), "A13824"); + assert_eq!(client.limits().max_calls_in_request(), 32); + assert!(client.has_capability(CAPABILITY_MAIL)); + assert!(client.has_mail()); + assert!(!client.has_capability("urn:ietf:params:jmap:calendars")); + let blob = Id::new("Gb1").unwrap(); + assert_eq!( + client.download_address(&blob, "image/png", "photo 1.png").unwrap().as_str(), + "https://jmap.example.com/download/A13824/Gb1/photo%201.png?accept=image%2Fpng" + ); + assert_eq!(client.upload_address().unwrap().as_str(), "https://jmap.example.com/upload/A13824/"); + assert!(matches!(client.download_address(&blob, "image/png", ".."), Err(Error::Template(_)))); +} + +// RFC 8620 §3.1, §3.3, §3.4 and §3.7: a request with a result reference, and its per-call results. +#[test] +fn calls_the_api() { + let client = client(); + let mut request = ApiRequest::new(); + request.using(CAPABILITY_MAIL).unwrap(); + let query = request.add_call("Email/query", Map::from_iter([("accountId".to_owned(), json!("A13824"))])).unwrap(); + let ids = request.reference(&query, "Email/query", "/ids").unwrap(); + let get = request.add_call_with_references("Email/get", Map::from_iter([("accountId".to_owned(), json!("A13824"))]), vec![("ids", ids)]).unwrap(); + let answer = json!({ + "methodResponses": [ + ["Email/query", {"accountId": "A13824", "ids": ["M1"], "position": 0}, "c0"], + ["error", {"type": "invalidResultReference"}, "c1"] + ], + "sessionState": "changed" + }); + let exchange = FakeExchange::new(vec![Ok(json_response(200, &answer))]); + let mut response = block_on(client.call(&exchange, &request)).unwrap(); + assert!(client.is_session_stale(&response)); + assert_eq!(response.take_named(&query, "Email/query").unwrap()["ids"], json!(["M1"])); + assert!(matches!(response.take(&get), Err(crate::response::CallFailure::Method(_)))); + let sent = &exchange.sent()[0]; + assert_eq!(sent.method(), Method::Post); + assert_eq!(sent.url(), "https://jmap.example.com/api/"); + let body: Value = serde_json::from_slice(sent.body()).unwrap(); + assert_eq!(body["using"], json!(["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"])); +} + +// RFC 8620 §2 limits: maxCallsInRequest and maxSizeRequest are enforced before sending. +#[test] +fn enforces_limits_before_sending() { + let client = client(); + let mut request = ApiRequest::new(); + for _ in 0..33 { + request.add_call("Core/echo", Map::new()).unwrap(); + } + assert_eq!(client.api_request(&request).unwrap_err(), Error::Build(BuildError::TooManyCalls { limit: 32 })); + let mut request = ApiRequest::new(); + request.add_call("Core/echo", Map::from_iter([("blob".to_owned(), json!("x".repeat(10_000_001)))])).unwrap(); + assert_eq!(client.api_request(&request).unwrap_err(), Error::Build(BuildError::TooLarge { limit: 10_000_000 })); + let mut request = ApiRequest::new(); + request.using("urn:ietf:params:jmap:calendars").unwrap(); + request.add_call("Core/echo", Map::new()).unwrap(); + assert_eq!(client.api_request(&request).unwrap_err(), Error::Build(BuildError::UnsupportedCapability)); +} + +// Requirement 2.4 through the client: busy, credentials, transport overload, unusable answers. +#[test] +fn classifies_api_failures() { + let client = client(); + let mut request = ApiRequest::new(); + request.add_call("Core/echo", Map::new()).unwrap(); + let cases: Vec<(Result, Error)> = vec![ + ( + Ok(HttpResponse::new(429, Vec::new()).with_field("Retry-After", "7")), + Error::Busy { retry_after: Some(RetryAfter::Delay(Duration::from_secs(7))) }, + ), + (Ok(HttpResponse::new(401, Vec::new())), Error::CredentialsRefused), + (Err(TransportError::Overloaded), Error::Busy { retry_after: None }), + (Err(TransportError::TimedOut), Error::Transport(TransportError::TimedOut)), + (Ok(HttpResponse::new(200, b"".to_vec()).with_field("Content-Type", "text/html")), Error::Unusable(Unusable::WrongContentType)), + (Ok(HttpResponse::new(200, b"{\"x\":".to_vec()).with_field("Content-Type", "application/json")), Error::Unusable(Unusable::InvalidJson)), + ]; + for (answer, expected) in cases { + let exchange = FakeExchange::new(vec![answer]); + assert_eq!(block_on(client.call(&exchange, &request)).unwrap_err(), expected); + } +} + +// Requirement 3.5 through the client: the user name, the account name and the password never show. +#[test] +fn errors_hide_secrets() { + let client = client(); + let mut request = ApiRequest::new(); + request.add_call("Core/echo", Map::new()).unwrap(); + let problem = json!({"status": 500, "detail": "user john@example.com with password open-sesame-42 at https://jmap.example.com/.well-known/jmap"}); + let response = HttpResponse::new(500, problem.to_string().into_bytes()).with_field("Content-Type", "application/problem+json"); + let exchange = FakeExchange::new(vec![Ok(response)]); + let error = block_on(client.call(&exchange, &request)).unwrap_err(); + let shown = error.to_string(); + assert!(!shown.contains("john") && !shown.contains("open-sesame") && !shown.contains("well-known")); +} + +// Requirement 2.3: the client's debug form shows no secret, no user name, no account id and no address. +#[test] +fn debug_shows_nothing_personal() { + let client = client(); + let debug = format!("{client:?}"); + for hidden in ["john", "jane", "open-sesame", "A13824", "jmap.example.com", "75128aab4b1b", "am9obk"] { + assert!(!debug.contains(hidden), "{hidden} shows in the client's debug form"); + } + assert!(debug.starts_with("Client { scheme: \"Basic\"")); +} + +// Requirement 3.5: a token in the session address's query is redacted on its own, and a short server-chosen +// user name does not blank every message. +#[test] +fn session_address_parts_are_secrets() { + let address = Address::parse("https://jmap.example.com/.well-known/jmap?apikey=SECRETKEY123").unwrap(); + let text = EXAMPLE.replace(r#""username": "john@example.com""#, r#""username": "jo""#); + let session = Session::parse(text.as_bytes(), &address).unwrap(); + let client = Client::new(session, Credentials::bearer("token-0123456789").unwrap()).unwrap(); + assert_eq!(client.excerpt("invalid key SECRETKEY123 for jo").as_str(), "invalid key [redacted] for jo"); + assert_eq!(client.excerpt("bad token-0123456789").as_str(), "bad [redacted]"); +} + +// RFC 8620 §6.2: a download is an authenticated GET of the expanded template; the body comes back as is. +#[test] +fn downloads() { + let client = client(); + let exchange = FakeExchange::new(vec![Ok(HttpResponse::new(200, vec![0, 159, 146, 150]).with_field("Content-Type", "application/octet-stream"))]); + let bytes = block_on(client.download(&exchange, &Id::new("B1").unwrap(), "application/octet-stream", "a.bin", None)).unwrap(); + assert_eq!(bytes, vec![0, 159, 146, 150]); + assert_eq!(exchange.sent()[0].url(), "https://jmap.example.com/download/A13824/B1/a.bin?accept=application%2Foctet-stream"); + let exchange = FakeExchange::new(vec![Ok(HttpResponse::new(404, Vec::new()))]); + assert_eq!(block_on(client.download(&exchange, &Id::new("B1").unwrap(), "", "a.bin", None)), Err(Error::NotFound)); +} + +// Requirement 2.2: a limit the caller sets for one request reaches the exchange before the body is read, +// and a body above it is refused, whether the exchange stops early or hands the whole body over. +#[test] +fn per_request_limits() { + let client = client(); + let blob = Id::new("B1").unwrap(); + let body = || Ok(HttpResponse::new(200, vec![7; 5])); + let careful = FakeExchange::capping(vec![body()]); + assert_eq!(block_on(client.download(&careful, &blob, "", "a.bin", Some(4))), Err(Error::Unusable(Unusable::BodyTooLarge))); + assert_eq!(careful.sent()[0].max_response_bytes(), 4); + let careless = FakeExchange::new(vec![body()]); + assert_eq!(block_on(client.download(&careless, &blob, "", "a.bin", Some(4))), Err(Error::Unusable(Unusable::BodyTooLarge))); + let fits = FakeExchange::capping(vec![body()]); + assert_eq!(block_on(client.download(&fits, &blob, "", "a.bin", Some(5))).unwrap(), vec![7; 5]); + let unlimited = FakeExchange::new(vec![body()]); + block_on(client.download(&unlimited, &blob, "", "a.bin", None)).unwrap(); + assert_eq!(unlimited.sent()[0].max_response_bytes(), crate::http::DOWNLOAD_RESPONSE_LIMIT); + + let mut request = ApiRequest::new(); + request.add_call("Core/echo", Map::new()).unwrap(); + request.set_response_limit(64); + assert_eq!(client.api_request(&request).unwrap().max_response_bytes(), 64); + let answer = json!({"methodResponses": [["Core/echo", {"pad": "x".repeat(100)}, "c0"]], "sessionState": "s"}); + let exchange = FakeExchange::capping(vec![Ok(json_response(200, &answer))]); + assert_eq!(block_on(client.call(&exchange, &request)).unwrap_err(), Error::Unusable(Unusable::BodyTooLarge)); + request.set_response_limit(usize::MAX); + assert_eq!(client.api_request(&request).unwrap().max_response_bytes(), crate::http::API_RESPONSE_LIMIT); +} + +// Requirement 3.2: no mail account, no client. +#[test] +fn needs_a_mail_account() { + let text = EXAMPLE.replace("urn:ietf:params:jmap:mail\": {", "urn:example:other\": {"); + let session = Session::parse(text.as_bytes(), &session_address()).unwrap(); + assert_eq!(session.mail_account(), None); + assert_eq!(Client::new(session, credentials()).unwrap_err(), Error::NoMailAccount); +} + +// Requirement 2.6 context (RFC 8620 §3.1 calls made through the caller's exchange): the helpers' futures are +// Send when the exchange's are, so native programs can spawn them. +#[test] +fn futures_are_send() { + fn assert_send(_: &T) {} + let client = client(); + let exchange = FakeExchange::new(Vec::new()); + let request = ApiRequest::new(); + let future = client.call(&exchange, &request); + assert_send(&future); + let address = session_address(); + let credentials = credentials(); + assert_send(&fetch_session(&exchange, &address, &credentials)); +} diff --git a/crates/jmap/src/credentials.rs b/crates/jmap/src/credentials.rs new file mode 100644 index 000000000..5585602e4 --- /dev/null +++ b/crates/jmap/src/credentials.rs @@ -0,0 +1,245 @@ +//! Credentials: HTTP Basic (RFC 7617) and Bearer tokens (RFC 6750). +//! +//! Credentials live in memory only. Their `Debug` form names the scheme and nothing else: no secret and no +//! user name, which is usually an e-mail address and so personal data. The memory holding a secret is +//! overwritten when it is dropped; this is best effort (the allocator, the operating system or an earlier +//! copy made by the caller are out of reach without `unsafe`), not a guarantee. + +use std::fmt; + +use crate::redact::Redactor; + +/// The longest user name, password or token accepted, in octets. +pub const MAX_CREDENTIAL_LEN: usize = 8 * 1024; + +/// A piece of text that must not be shown, and that is overwritten when dropped. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct Secret(String); + +impl Secret { + pub(crate) fn new(text: String) -> Self { + Self(text) + } + + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +impl Drop for Secret { + fn drop(&mut self) { + let mut bytes = std::mem::take(&mut self.0).into_bytes(); + bytes.iter_mut().for_each(|b| *b = 0); + std::hint::black_box(&bytes); + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("") + } +} + +/// Why credentials were refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum CredentialsError { + /// The user name is empty. + EmptyUser, + /// The token is empty. + EmptyToken, + /// The user name holds a colon, which Basic cannot carry (RFC 7617 §2). + ColonInUser, + /// A control character in the user name or password (RFC 7617 §2). + ControlCharacter, + /// The token holds a character RFC 6750 §2.1 does not allow. + InvalidToken, + /// Longer than [`MAX_CREDENTIAL_LEN`]. + TooLong, +} + +impl fmt::Display for CredentialsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::EmptyUser => "the user name is empty; enter the user name of the mail account", + Self::EmptyToken => "the access token is empty; sign in again to obtain one", + Self::ColonInUser => { + "the user name contains a colon (:), which password sign-in cannot carry; use a token instead, or the user name without it" + } + Self::ControlCharacter => "the user name or password contains an invisible control character, often from a paste; type it again", + Self::InvalidToken => { + "the access token contains a space, a comma or another character a token cannot hold; copy it again without extra characters" + } + Self::TooLong => "the user name, password or token is longer than 8 KiB, more than this program accepts; check what was pasted", + }) + } +} + +impl std::error::Error for CredentialsError {} + +#[derive(Clone)] +enum Kind { + Basic { user: Secret, password: Secret }, + Bearer { token: Secret }, +} + +/// Credentials for the JMAP server. +#[derive(Clone)] +pub struct Credentials { + kind: Kind, + authorization: Secret, +} + +const BASE64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn base64_char(index: u8) -> char { + char::from(BASE64_ALPHABET.get(usize::from(index & 0x3F)).copied().unwrap_or(b'A')) +} + +/// Base64 with padding (RFC 4648 §4), written into `out`. +fn base64_into(input: &[u8], out: &mut String) { + for chunk in input.chunks(3) { + let (a, b, c) = match *chunk { + [a, b, c] => (a, Some(b), Some(c)), + [a, b] => (a, Some(b), None), + [a] => (a, None, None), + _ => continue, + }; + let b0 = b.unwrap_or(0); + let c0 = c.unwrap_or(0); + out.push(base64_char(a >> 2)); + out.push(base64_char(((a & 0x03) << 4) | (b0 >> 4))); + out.push(if b.is_some() { base64_char(((b0 & 0x0F) << 2) | (c0 >> 6)) } else { '=' }); + out.push(if c.is_some() { base64_char(c0 & 0x3F) } else { '=' }); + } +} + +/// The part an octet may play in a bearer token (RFC 6750 §2.1, `b64token`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TokenOctet { + /// A letter, a digit, or one of `-` `.` `_` `~` `+` `/`: allowed anywhere before the padding. + Body, + /// `=`: allowed only at the end, any number of times. + Padding, + /// Anything else, spaces, commas, quotes, controls and non-ASCII octets among them. + Refused, +} + +/// Sorts one octet into its [`TokenOctet`] part. +const fn token_octet(octet: u8) -> TokenOctet { + match octet { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => TokenOctet::Body, + b'-' | b'.' | b'_' | b'~' | b'+' | b'/' => TokenOctet::Body, + b'=' => TokenOctet::Padding, + _ => TokenOctet::Refused, + } +} + +/// Reads a bearer token in one pass: at least one body octet, then nothing but padding once padding began. +fn is_bearer_token(token: &str) -> bool { + let mut body_octets = 0usize; + let mut padding_started = false; + for octet in token.bytes() { + match (token_octet(octet), padding_started) { + (TokenOctet::Body, false) => body_octets = body_octets.saturating_add(1), + (TokenOctet::Padding, _) => padding_started = true, + (TokenOctet::Body, true) | (TokenOctet::Refused, _) => return false, + } + } + body_octets > 0 +} + +impl Credentials { + /// Basic credentials (RFC 7617 §2): `user:password` in UTF-8, base64-encoded. + /// + /// The user name may not be empty or hold a colon; neither part may hold a control character. The text is + /// sent as given: Unicode normalisation (RFC 7617 §2.1 mentions NFC) is left to the caller. + pub fn basic(user: &str, password: &str) -> Result { + if user.is_empty() { + return Err(CredentialsError::EmptyUser); + } + if user.len() > MAX_CREDENTIAL_LEN || password.len() > MAX_CREDENTIAL_LEN { + return Err(CredentialsError::TooLong); + } + if user.contains(':') { + return Err(CredentialsError::ColonInUser); + } + if user.chars().chain(password.chars()).any(char::is_control) { + return Err(CredentialsError::ControlCharacter); + } + let mut pair = Vec::with_capacity(user.len().saturating_add(password.len()).saturating_add(1)); + pair.extend_from_slice(user.as_bytes()); + pair.push(b':'); + pair.extend_from_slice(password.as_bytes()); + let encoded_len = pair.len().div_ceil(3).saturating_mul(4); + let mut header = String::with_capacity(encoded_len.saturating_add(6)); + header.push_str("Basic "); + base64_into(&pair, &mut header); + pair.iter_mut().for_each(|b| *b = 0); + std::hint::black_box(&pair); + Ok(Self { + kind: Kind::Basic { user: Secret::new(user.to_owned()), password: Secret::new(password.to_owned()) }, + authorization: Secret::new(header), + }) + } + + /// Bearer credentials (RFC 6750 §2.1). The token must match `b64token`. + pub fn bearer(token: &str) -> Result { + if token.is_empty() { + return Err(CredentialsError::EmptyToken); + } + if token.len() > MAX_CREDENTIAL_LEN { + return Err(CredentialsError::TooLong); + } + if !is_bearer_token(token) { + return Err(CredentialsError::InvalidToken); + } + let mut header = String::with_capacity(token.len().saturating_add(7)); + header.push_str("Bearer "); + header.push_str(token); + Ok(Self { kind: Kind::Bearer { token: Secret::new(token.to_owned()) }, authorization: Secret::new(header) }) + } + + /// The authentication scheme's name: `Basic` or `Bearer`. + pub fn scheme_name(&self) -> &'static str { + match self.kind { + Kind::Basic { .. } => "Basic", + Kind::Bearer { .. } => "Bearer", + } + } + + /// The value of the `Authorization` header field. + pub(crate) fn authorization(&self) -> &Secret { + &self.authorization + } + + /// Teaches a redactor every secret these credentials hold. + pub fn register_secrets(&self, redactor: &mut Redactor) { + match &self.kind { + Kind::Basic { user, password } => { + redactor.add_personal(user.expose()); + redactor.add(password.expose()); + } + Kind::Bearer { token } => redactor.add(token.expose()), + } + let header = self.authorization.expose(); + let value = header.split_once(' ').map_or(header, |(_, value)| value); + redactor.add(value); + if let Kind::Basic { .. } = self.kind { + // The base64 forms a server might echo: without padding, and in the URL-safe alphabet (RFC 4648 §5). + let unpadded = value.trim_end_matches('='); + redactor.add(unpadded); + let url_safe = Secret::new(unpadded.replace('+', "-").replace('/', "_")); + redactor.add(url_safe.expose()); + } + } +} + +impl fmt::Debug for Credentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Credentials({}, )", self.scheme_name()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/jmap/src/credentials/tests.rs b/crates/jmap/src/credentials/tests.rs new file mode 100644 index 000000000..2b2973a2a --- /dev/null +++ b/crates/jmap/src/credentials/tests.rs @@ -0,0 +1,107 @@ +use super::*; +use crate::test_support::made_secret; + +// RFC 7617 §2: the "Aladdin" / "open sesame" example. +#[test] +fn basic_example() { + let credentials = Credentials::basic("Aladdin", &made_secret("open sesame")).unwrap(); + assert_eq!(credentials.authorization().expose(), "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="); +} + +// RFC 7617 §2.1: the UTF-8 example ("test" / "123£"). +#[test] +fn basic_utf8_example() { + let credentials = Credentials::basic("test", &made_secret("123\u{A3}")).unwrap(); + assert_eq!(credentials.authorization().expose(), "Basic dGVzdDoxMjPCow=="); +} + +// RFC 4648 §10: the base64 test vectors. +#[test] +fn base64_vectors() { + for (input, expected) in + [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] + { + let mut out = String::new(); + base64_into(input.as_bytes(), &mut out); + assert_eq!(out, expected); + } +} + +// RFC 7617 §2: no colon in the user name, no control characters anywhere. +#[test] +fn basic_refusals() { + assert_eq!(Credentials::basic("", &made_secret("x")).unwrap_err(), CredentialsError::EmptyUser); + assert_eq!(Credentials::basic("a:b", &made_secret("x")).unwrap_err(), CredentialsError::ColonInUser); + assert_eq!(Credentials::basic("user", &made_secret("pa\nss")).unwrap_err(), CredentialsError::ControlCharacter); + assert_eq!(Credentials::basic("us\u{7f}er", &made_secret("pass")).unwrap_err(), CredentialsError::ControlCharacter); + assert!(Credentials::basic("user", &made_secret("with:colon")).is_ok()); +} + +// RFC 6750 §2.1: the specification's example token, and an empty token. +#[test] +fn bearer_tokens() { + let credentials = Credentials::bearer("mF_9.B5f-4.1JqM").unwrap(); + assert_eq!(credentials.authorization().expose(), "Bearer mF_9.B5f-4.1JqM"); + assert_eq!(Credentials::bearer("").unwrap_err(), CredentialsError::EmptyToken); +} + +// RFC 6750 §2.1, rule 1: the body is made of ALPHA, DIGIT and the six marks - . _ ~ + /. +#[test] +fn token_body_octets() { + for token in ["AZaz09", "a-b", "a.b", "a_b", "a~b", "a+b", "a/b", "-._~+/"] { + assert!(is_bearer_token(token), "{token}"); + } + for octet in 0u8..=255 { + let expected = octet.is_ascii_alphanumeric() || b"-._~+/".contains(&octet); + assert_eq!(token_octet(octet) == TokenOctet::Body, expected, "{octet}"); + } +} + +// RFC 6750 §2.1, rule 2: at least one body octet ("1*"). +#[test] +fn token_needs_a_body() { + for token in ["", "=", "==="] { + assert!(!is_bearer_token(token), "{token:?}"); + } + assert_eq!(Credentials::bearer("==").unwrap_err(), CredentialsError::InvalidToken); +} + +// RFC 6750 §2.1, rule 3: "=" only at the end, any number of times ("*\"=\""). +#[test] +fn token_padding_only_at_the_end() { + for token in ["abc=", "abc==", "abc===="] { + assert!(is_bearer_token(token), "{token}"); + } + for token in ["=abc", "a=b", "ab=c=", "ab==c"] { + assert!(!is_bearer_token(token), "{token}"); + } +} + +// RFC 6750 §2.1, rule 4: nothing else, which also rules out header injection (RFC 9110 §5.5). +#[test] +fn token_refused_octets() { + for token in ["a b", "a\r\nX-Injected: 1", "a,b", "a\"b", "a:b", "tok\u{e9}n", "a\tb", "a;b"] { + assert!(!is_bearer_token(token), "{token:?}"); + assert_eq!(Credentials::bearer(token).unwrap_err(), CredentialsError::InvalidToken, "{token:?}"); + } +} + +// Requirement 2.3: no secret and no user name in the debug form. +#[test] +fn debug_shows_no_secret() { + let basic = Credentials::basic("john@example.com", &made_secret("hunter2-long")).unwrap(); + let shown = format!("{basic:?}"); + assert_eq!(shown, "Credentials(Basic, )"); + let bearer = Credentials::bearer("tok-123456").unwrap(); + assert!(!format!("{bearer:?}").contains("tok-123456")); +} + +// Requirement 3.5: the redactor learns the user name, the password and the encoded header value. +#[test] +fn registers_secrets() { + let credentials = Credentials::basic("john@example.com", &made_secret("open sesame")).unwrap(); + let mut redactor = Redactor::new(); + credentials.register_secrets(&mut redactor); + let excerpt = redactor.excerpt("john@example.com open sesame am9obkBleGFtcGxlLmNvbTpvcGVuIHNlc2FtZQ=="); + assert_eq!(excerpt.as_str(), "[redacted] [redacted] [redacted]"); +} diff --git a/crates/jmap/src/discovery.rs b/crates/jmap/src/discovery.rs new file mode 100644 index 000000000..65ca76041 --- /dev/null +++ b/crates/jmap/src/discovery.rs @@ -0,0 +1,60 @@ +//! Turning what a person types into the JMAP session address to fetch (requirement 1.6, RFC 8620 §2.2). +//! +//! A person may type `example.com`, `example.com/jmap/session`, `example.com:8443`, `https://example.com` or a +//! full address. The result defaults to `https`, and to [`WELL_KNOWN_PATH`] when only a host was given. Plain +//! `http` is kept only when it was typed and the host is loopback (requirement 1.2); it is never chosen by +//! this function, and `http` typed for a remote host is refused rather than silently upgraded, so that the +//! person sees that what they typed cannot be used. + +use crate::address::{Address, AddressError, MAX_ADDRESS_LEN}; + +/// The well-known path of the JMAP session resource (RFC 8620 §2.2, registered by §9.2). +pub const WELL_KNOWN_PATH: &str = "/.well-known/jmap"; + +/// Whether `text` begins with `scheme://`, the scheme being well formed (RFC 3986 §3.1). +fn has_explicit_scheme(text: &str) -> bool { + match text.split_once("://") { + Some((scheme, _)) => { + let mut bytes = scheme.bytes(); + matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic()) && bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) + } + None => false, + } +} + +/// Whether `text` starts with `http:` or `https:` without the `//` that must follow (`https:example.com`). +fn has_scheme_without_slashes(text: &str) -> bool { + let lower = text.get(..6).unwrap_or(text).to_ascii_lowercase(); + (lower.starts_with("http:") || lower.starts_with("https:")) && !text.contains("://") +} + +/// Turns a session address typed by a person into the address to fetch. +/// +/// Surrounding white space is ignored; anything else that is not a valid address is refused with the same +/// errors as [`Address::parse`]. The result always satisfies the transport rule. +pub fn session_address(input: &str) -> Result { + let trimmed = input.trim_matches(|c: char| c.is_ascii_whitespace()); + if trimmed.is_empty() { + return Err(AddressError::Empty); + } + if trimmed.len() > MAX_ADDRESS_LEN { + return Err(AddressError::TooLong); + } + if has_scheme_without_slashes(trimmed) { + return Err(AddressError::MissingAuthority); + } + let address = if has_explicit_scheme(trimmed) { + Address::parse(trimmed)? + } else { + let mut with_scheme = String::with_capacity(trimmed.len().saturating_add(8)); + with_scheme.push_str("https://"); + with_scheme.push_str(trimmed); + Address::parse(&with_scheme)? + }; + let address = if address.path() == "/" && address.query().is_none() { address.with_path(WELL_KNOWN_PATH)? } else { address }; + address.require_secure_transport()?; + Ok(address) +} + +#[cfg(test)] +mod tests; diff --git a/crates/jmap/src/discovery/tests.rs b/crates/jmap/src/discovery/tests.rs new file mode 100644 index 000000000..f38cdeaa6 --- /dev/null +++ b/crates/jmap/src/discovery/tests.rs @@ -0,0 +1,53 @@ +use super::*; + +fn fetched(input: &str) -> String { + session_address(input).unwrap().as_str().to_owned() +} + +// RFC 8620 §2.2: a bare host means https and the well-known path. +#[test] +fn bare_host_gets_well_known_path() { + assert_eq!(fetched("example.com"), "https://example.com/.well-known/jmap"); + assert_eq!(fetched(" Example.COM \n"), "https://example.com/.well-known/jmap"); + assert_eq!(fetched("example.com/"), "https://example.com/.well-known/jmap"); + assert_eq!(fetched("example.com:8443"), "https://example.com:8443/.well-known/jmap"); + assert_eq!(fetched("https://example.com"), "https://example.com/.well-known/jmap"); + assert_eq!(fetched("[::1]:8080"), "https://[::1]:8080/.well-known/jmap"); +} + +// Requirement 1.6: a host with a path keeps the path; https is the default scheme. +#[test] +fn host_with_path_keeps_it() { + assert_eq!(fetched("example.com/jmap/session"), "https://example.com/jmap/session"); + assert_eq!(fetched("jmap.example.org:8443/api/session?x=1"), "https://jmap.example.org:8443/api/session?x=1"); + assert_eq!(fetched("HTTPS://example.net/jmap/"), "https://example.net/jmap/"); + assert_eq!(fetched("example.com/?a=b"), "https://example.com/?a=b"); +} + +// Requirement 1.2 and 1.6: never a downgrade; http only as typed, and only to loopback. +#[test] +fn never_downgrades() { + assert_eq!(fetched("localhost:8080"), "https://localhost:8080/.well-known/jmap"); + assert_eq!(fetched("http://localhost:8080"), "http://localhost:8080/.well-known/jmap"); + assert_eq!(fetched("http://127.0.0.1:8080/jmap"), "http://127.0.0.1:8080/jmap"); + assert_eq!(session_address("http://example.com"), Err(AddressError::InsecureTransport)); + assert_eq!(session_address("http://sub.localhost/"), Err(AddressError::InsecureTransport)); +} + +// Requirement 1.1 applied to typed input: what is not an address is refused. +#[test] +fn refuses_junk() { + assert_eq!(session_address(""), Err(AddressError::Empty)); + assert_eq!(session_address(" "), Err(AddressError::Empty)); + assert_eq!(session_address("https:example.com"), Err(AddressError::MissingAuthority)); + assert_eq!(session_address("http:localhost"), Err(AddressError::MissingAuthority)); + assert_eq!(session_address("ftp://example.com"), Err(AddressError::UnsupportedScheme)); + assert_eq!(session_address("user@example.com"), Err(AddressError::UserInfo)); + assert_eq!(session_address("exa mple.com"), Err(AddressError::ForbiddenCharacter)); + assert_eq!(session_address("example.com\\jmap"), Err(AddressError::Backslash)); + assert_eq!(session_address("bücher.example.com"), Err(AddressError::ForbiddenCharacter)); + assert_eq!( + session_address("example.com/path?u=http://example.org"), + Ok(Address::parse("https://example.com/path?u=http://example.org").unwrap()) + ); +} diff --git a/crates/jmap/src/error.rs b/crates/jmap/src/error.rs new file mode 100644 index 000000000..4db0f26c6 --- /dev/null +++ b/crates/jmap/src/error.rs @@ -0,0 +1,412 @@ +//! The crate's error type, arranged by what the mail client can do about each case. + +use std::fmt; + +use crate::address::{Address, AddressError}; +use crate::credentials::CredentialsError; +use crate::http::TransportError; +use crate::id::IdError; +use crate::redact::Excerpt; +use crate::retry::RetryAfter; +use crate::template::TemplateError; + +/// The longest limit name kept from a server. +const MAX_LIMIT_NAME_LEN: usize = 64; + +/// The name of a JMAP limit a server says was exceeded (`maxSizeRequest`, `maxCallsInRequest`, …), checked to +/// be a short identifier (letters, digits and `_`). +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct LimitName(String); + +impl LimitName { + /// Checks a limit name received from a server. + pub fn new(text: &str) -> Option { + if text.is_empty() || text.len() > MAX_LIMIT_NAME_LEN || !text.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + return None; + } + Some(Self(text.to_owned())) + } + + /// The name. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Whether the limit is about concurrency (`maxConcurrentRequests`, `maxConcurrentUpload`) rather than size. + pub fn is_concurrency(&self) -> bool { + self.0.starts_with("maxConcurrent") + } +} + +impl fmt::Display for LimitName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl fmt::Debug for LimitName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "LimitName({})", self.0) + } +} + +/// A request-level JMAP problem (RFC 8620 §3.6.1), the `urn:ietf:params:jmap:error:` prefix removed. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RequestProblem { + /// `unknownCapability`: the request used a capability the server does not support. + UnknownCapability, + /// `notJSON`: the request was not `application/json` or not I-JSON. + NotJson, + /// `notRequest`: the request did not have the shape of a Request object. + NotRequest, + /// `limit`: a request limit was exceeded. + Limit, + /// Another JMAP problem type, checked to be a short identifier. + Other(String), +} + +impl fmt::Display for RequestProblem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownCapability => f.write_str("it uses a capability this server does not support"), + Self::NotJson => f.write_str("the server could not read it as JSON"), + Self::NotRequest => f.write_str("its JSON is not shaped like a JMAP request"), + Self::Limit => f.write_str("it goes beyond one of the server's request limits"), + Self::Other(name) if name.is_empty() => f.write_str("the server reported an unnamed JMAP problem"), + Self::Other(name) => write!(f, "the server reported the JMAP problem {name}"), + } + } +} + +/// Why a server's successful-looking answer cannot be used. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Unusable { + /// The content type is not the one expected (`application/json`). + WrongContentType, + /// The body is larger than the request's limit ([`HttpRequest::max_response_bytes`](crate::HttpRequest::max_response_bytes): + /// the kind's, or the lower one the caller set), whether the crate or the transport found it. + BodyTooLarge, + /// The body is not valid JSON (RFC 8259), or nests too deeply. + InvalidJson, + /// The body holds more JSON values or objects than the crate accepts. + TooComplex, + /// The JSON does not have the expected shape; the text says which part, in the crate's own words. + Shape(&'static str), + /// An address the server gave cannot be used; the text names the field. + Address(&'static str, AddressError), + /// An address template the server gave cannot be used; the text names the field. + Template(&'static str, TemplateError), +} + +impl fmt::Display for Unusable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongContentType => f.write_str("its content type is not application/json"), + Self::BodyTooLarge => f.write_str("it is larger than the size limit set for this request"), + Self::InvalidJson => f.write_str("it is not valid JSON, or nests more than 128 levels deep"), + Self::TooComplex => f.write_str("it holds more JSON values or objects than this program reads at once"), + Self::Shape(what) => write!(f, "{what}"), + Self::Address(field, error) => write!(f, "its {field} cannot be used: {error}"), + Self::Template(field, error) => write!(f, "its {field} cannot be used: {error}"), + } + } +} + +/// A mistake in a request being built, found before anything is sent. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum BuildError { + /// A method name that is empty, too long, or not printable ASCII. + InvalidMethodName, + /// A capability identifier that is empty, too long, or not printable ASCII. + InvalidCapability, + /// A capability the session does not advertise. + UnsupportedCapability, + /// An argument given both plainly and as a result reference (RFC 8620 §3.7). + DuplicateArgument, + /// An argument name that is empty or starts with `#`. + InvalidArgumentName, + /// A result reference to a call that is not earlier in this request (RFC 8620 §3.7). + UnknownCall, + /// A result reference path that is not a JSON Pointer (RFC 6901, with JMAP's `*`). + InvalidPath, + /// More calls than the server accepts (`maxCallsInRequest`) or than the crate builds. + TooManyCalls { + /// The limit that applies. + limit: u64, + }, + /// A request body larger than the server accepts (`maxSizeRequest`). + TooLarge { + /// The limit that applies, in octets. + limit: u64, + }, + /// A request without any method call. + NoCalls, + /// The request could not be written as JSON. + Serialisation, +} + +impl fmt::Display for BuildError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidMethodName => f.write_str("the method name is empty, longer than 255 characters, or holds a space or non-ASCII character"), + Self::InvalidCapability => { + f.write_str("the capability identifier is empty, longer than 256 characters, or holds a space or non-ASCII character") + } + Self::UnsupportedCapability => { + f.write_str("the request uses a capability this server's session does not offer; remove it or check the server") + } + Self::DuplicateArgument => f.write_str("an argument is given both directly and as a result reference (#name); keep only one"), + Self::InvalidArgumentName => { + f.write_str("a result reference argument name is empty or starts with #; give the plain name, the # is added") + } + Self::UnknownCall => f.write_str("a result reference names a call that is not earlier in this same request"), + Self::InvalidPath => { + f.write_str("a result reference path is not a JSON pointer: it must be empty or start with /, and use ~ only as ~0 or ~1") + } + Self::TooManyCalls { limit } => { + write!(f, "the request holds more than {limit} method calls, the server's limit; send it in several requests") + } + Self::TooLarge { limit } => { + write!(f, "the request body is larger than the server's limit of {limit} octets; send it in several requests") + } + Self::NoCalls => f.write_str("the request holds no method call, so there is nothing to send"), + Self::Serialisation => f.write_str("the request could not be written out as JSON"), + } + } +} + +/// Everything that can go wrong, arranged by what the mail client can do about it. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// The program's HTTP exchange failed before a response came (offline, timeout, TLS, …). + Transport(TransportError), + /// The server is busy or limiting the rate (429, 503, connection-level overload, a concurrency limit): + /// try again later, after at least the delay it asked for when there is one. + Busy { + /// What the server asked for, if anything usable. + retry_after: Option, + }, + /// The credentials were refused (401, or 403 when the problem details say so): ask for them again. + CredentialsRefused, + /// The request was too large for the server (413, or a JMAP `limit` problem): split it. + TooLarge { + /// The limit the server named, if any. + limit: Option, + }, + /// The server refuses access to this resource (403). + Forbidden { + /// What the server said, cleaned. + detail: Option, + }, + /// Nothing at that address (404, 410). + NotFound, + /// The server rejected the request as malformed or unsupported (400 and other client errors, or a JMAP + /// request-level problem). + Refused { + /// The JMAP problem, if one was recognised. + problem: Option, + /// What the server said, cleaned. + detail: Option, + }, + /// The server redirected; the crate follows nothing. The target has passed the transport rule and the + /// loopback check; whether to follow (and whether to send the credentials to another origin) is the + /// caller's decision. + Redirected { + /// Where the server points. + target: Address, + /// Whether the target has the same origin as the request. + same_origin: bool, + }, + /// The server redirected to an address that may not be used (plain http, loopback from a remote host, + /// malformed). + RedirectRefused(AddressError), + /// The server answered something that cannot be used. + Unusable(Unusable), + /// Any other HTTP status. + Http { + /// The status code. + status: u16, + /// What the server said, cleaned. + detail: Option, + }, + /// An address given to the crate is not usable. + Address(AddressError), + /// An address template could not be expanded. + Template(TemplateError), + /// Credentials given to the crate are not usable. + Credentials(CredentialsError), + /// An id given to the crate is not valid. + Id(IdError), + /// A request being built is wrong. + Build(BuildError), + /// The session offers no account for mail (RFC 8621 `urn:ietf:params:jmap:mail`). + NoMailAccount, +} + +impl Error { + /// The delay before trying again, for [`Error::Busy`]. + pub fn retry_after(&self) -> Option<&RetryAfter> { + match self { + Self::Busy { retry_after } => retry_after.as_ref(), + _ => None, + } + } +} + +fn with_detail(f: &mut fmt::Formatter<'_>, text: &str, detail: Option<&Excerpt>) -> fmt::Result { + f.write_str(text)?; + if let Some(detail) = detail { + write!(f, " (server said: \"{detail}\")")?; + } + Ok(()) +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transport(error) => write!(f, "the request did not complete: {error}"), + Self::Busy { + retry_after: Some(RetryAfter::Delay(delay)), + } => write!( + f, + "the server is busy and asks to wait {} s before the next attempt", + delay.as_secs() + ), + Self::Busy { + retry_after: Some(RetryAfter::At(_)), + } => f.write_str("the server is busy and named a time before which not to try again"), + Self::Busy { retry_after: None } => { + f.write_str("the server is busy or limiting requests; wait a little before trying again") + } + Self::CredentialsRefused => f.write_str( + "the server rejected the user name and password (or the access token) for this account; check them, or sign in again if they were changed or have expired", + ), + Self::TooLarge { limit: Some(limit) } => write!( + f, + "the request went beyond the server's limit {limit}; send it in smaller parts" + ), + Self::TooLarge { limit: None } => { + f.write_str("the request was larger than the server accepts; send it in smaller parts") + } + Self::Forbidden { detail } => with_detail( + f, + "the server does not let this account reach that resource", + detail.as_ref(), + ), + Self::NotFound => f.write_str( + "the server has nothing at that address; it may have been moved or deleted, so refresh and try again", + ), + Self::Refused { + problem: Some(problem), + detail, + } => with_detail( + f, + &format!("the server rejected the request: {problem}"), + detail.as_ref(), + ), + Self::Refused { + problem: None, + detail, + } => with_detail(f, "the server rejected the request as invalid", detail.as_ref()), + Self::Redirected { + target, + same_origin: true, + } => write!( + f, + "the server moved this resource to another path on {}; it was not followed automatically", + target.origin() + ), + Self::Redirected { target, .. } => write!( + f, + "the server sent this request on to {}; confirm before following, since the credentials would go there too", + target.origin() + ), + Self::RedirectRefused(error) => write!( + f, + "the server redirected to an address that cannot be used: {error}" + ), + Self::Unusable(why) => write!(f, "the server's answer cannot be used: {why}"), + Self::Http { status, detail } => with_detail( + f, + &format!("the server answered with HTTP status {status}, which this program cannot act on"), + detail.as_ref(), + ), + Self::Address(error) => fmt::Display::fmt(error, f), + Self::Template(error) => fmt::Display::fmt(error, f), + Self::Credentials(error) => fmt::Display::fmt(error, f), + Self::Id(error) => fmt::Display::fmt(error, f), + Self::Build(error) => fmt::Display::fmt(error, f), + Self::NoMailAccount => f.write_str( + "the server offers no mail account to these credentials; check that mail is enabled for this account", + ), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Transport(error) => Some(error), + Self::RedirectRefused(error) | Self::Address(error) => Some(error), + Self::Template(error) => Some(error), + Self::Credentials(error) => Some(error), + Self::Id(error) => Some(error), + _ => None, + } + } +} + +impl From for Error { + /// A connection-level overload is the server being busy; a body cut at the request's limit is the same + /// failure as one the crate refuses after reading; anything else stays a transport failure. + fn from(error: TransportError) -> Self { + match error { + TransportError::Overloaded => Self::Busy { retry_after: None }, + TransportError::BodyTooLarge => Self::Unusable(Unusable::BodyTooLarge), + other => Self::Transport(other), + } + } +} + +impl From for Error { + fn from(error: AddressError) -> Self { + Self::Address(error) + } +} + +impl From for Error { + fn from(error: TemplateError) -> Self { + Self::Template(error) + } +} + +impl From for Error { + fn from(error: CredentialsError) -> Self { + Self::Credentials(error) + } +} + +impl From for Error { + fn from(error: IdError) -> Self { + Self::Id(error) + } +} + +impl From for Error { + fn from(error: BuildError) -> Self { + Self::Build(error) + } +} + +impl From for Error { + fn from(why: Unusable) -> Self { + Self::Unusable(why) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/jmap/src/error/tests.rs b/crates/jmap/src/error/tests.rs new file mode 100644 index 000000000..3a451c91e --- /dev/null +++ b/crates/jmap/src/error/tests.rs @@ -0,0 +1,39 @@ +use super::*; +use std::time::Duration; + +// RFC 8620 §3.6.1: the limit property names the limit; only short identifiers are kept. +#[test] +fn limit_names() { + assert_eq!(LimitName::new("maxSizeRequest").unwrap().as_str(), "maxSizeRequest"); + assert!(LimitName::new("maxConcurrentRequests").unwrap().is_concurrency()); + assert!(!LimitName::new("maxCallsInRequest").unwrap().is_concurrency()); + for bad in ["", "max size", "x\ny", &"a".repeat(65), "