mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
Add craft-jmap: the JMAP protocol core from MailCraft (#982)
MailCraft crates/jmap's protocol core at a44a450, moved as it is with all its tests: session, requests with result references, responses, ids, addresses and templates, credentials, retry and the judgement of HTTP answers, over any HttpExchange. The mail client on top stays in MailCraft. Co-authored-by: Martin Ball <98639962+benandez@users.noreply.github.com>
This commit is contained in:
1 parent
7abf3186da
commit
8c6dbccb8b
40 files changed
+6977
No files matched your search
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -172,6 +172,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
primitives (`.abr`, `.asl`, `.aco`, `.act`, `.ase`, `.grd`, `.pat`, `.atn`, `.csh`, `.shc`) and
|
||||
the Photoshop blocks of a layered TIFF. Bounded and panic-free on hostile input. Compositing
|
||||
and the mapping onto a document stay in each app.
|
||||
- [`craft-jmap`](crates/jmap/README.md): the JMAP protocol core (RFC 8620), from MailCraft, without
|
||||
sockets: session, requests of method calls with back-references, responses, ids, address
|
||||
templates, credentials and the judgement of HTTP answers, over any HTTP implementation. Bounded
|
||||
on hostile answers; what a program asks and makes of the answers stays in it.
|
||||
- [`craft-jpeg`](crates/jpeg/README.md): clean-room decoder for 12-bit and arithmetic-coded
|
||||
JPEG (T.81 SOF0/SOF1 at 12 bits, SOF9, SOF10), from PhotoCraft, and a frame probe for routing.
|
||||
Metadata stays in each app.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "craft-jmap"
|
||||
description = "JMAP (RFC 8620) protocol core without sockets: session, requests of method calls with result references, responses, ids, address templates and the judgement of HTTP answers, over any HTTP implementation."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,37 @@
|
||||
# craft-jmap
|
||||
|
||||
The JMAP protocol core (RFC 8620) without sockets: a program brings an HTTP implementation
|
||||
(`HttpExchange`), this crate describes each request and judges each answer.
|
||||
|
||||
- **Session**: `Client`, `fetch_session` / `read_session`, `Session`, `Account`, `CoreLimits`,
|
||||
the capabilities (`CAPABILITY_CORE`, `CAPABILITY_MAIL`).
|
||||
- **Requests**: `ApiRequest` of method calls with `CallId`s and `ResultReference`s
|
||||
(back-references), checked against the session's limits.
|
||||
- **Responses**: `ApiResponse`, `MethodResponse`, `MethodError` (RFC 8620 §3.6.2), per call.
|
||||
- **Addresses**: `Address` (absolute `http(s)` URLs, RFC 3986 and the WHATWG URL Standard), the
|
||||
transport rule (`https`, or plain `http` to loopback only), `session_address` for what a person
|
||||
types, `AddressTemplate` (RFC 6570 level 1, the download and upload templates).
|
||||
- **HTTP**: `HttpRequest`, `HttpResponse`, `judge_response` (status codes, problem details,
|
||||
JSON), `RetryAfter`, `Credentials` (Basic, Bearer), `Redactor` (excerpts of what a server says,
|
||||
with the credentials and addresses taken out).
|
||||
- `Id`: the JMAP id alphabet.
|
||||
|
||||
Method calls' arguments and results are `serde_json::Value`s: what a program asks
|
||||
(`Mailbox/get`, `Email/query`…) and what it makes of the answers stays in the program.
|
||||
|
||||
## Hostile input
|
||||
|
||||
Every size, count, nesting depth and string length a server sends is bounded; ids are held to
|
||||
the JMAP alphabet; nothing indexes into what came over the wire, and the protocol modules are
|
||||
checked with `clippy::indexing_slicing` and `clippy::arithmetic_side_effects`. Credentials and
|
||||
the addresses of the session and the account never reach error text or `Debug` output.
|
||||
|
||||
## Origin
|
||||
|
||||
MailCraft `crates/jmap` (the protocol core: `address`, `classify`, `client`, `credentials`,
|
||||
`discovery`, `error`, `host`, `http`, `id`, `percent`, `redact`, `request`, `response`, `retry`,
|
||||
`session`, `template`) at
|
||||
[a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5),
|
||||
by Martin Ball and the MailCraft contributors (MIT OR Apache-2.0), with all of its tests. The mail
|
||||
client that sits on it (the mailbox and email reads into MailCraft's sync model, the fetch of
|
||||
whole messages, the in-process test server) stays in MailCraft.
|
||||
@@ -0,0 +1,533 @@
|
||||
//! Absolute `http` and `https` addresses (RFC 3986, RFC 9110 §4.2), reference resolution (RFC 3986 §5.2),
|
||||
//! origins, and the transport rule that decides whether an address may carry credentials.
|
||||
//!
|
||||
//! An [`Address`] is always absolute, always `http` or `https`, always has a host, never has user information
|
||||
//! or a fragment, and is kept in a normal form (RFC 3986 §6.2.2 and §6.2.3, RFC 9110 §4.2.3): lower-case scheme
|
||||
//! and host, no default port, percent-escapes in upper case with escaped unreserved characters decoded, no
|
||||
//! dot-segments, and `/` for an empty path.
|
||||
//!
|
||||
//! Its `Debug` form shows the origin only, so that a path or a query holding something private never reaches
|
||||
//! a log by accident. [`Address::as_str`] gives the full form, for the request itself.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::host::Host;
|
||||
use crate::percent::{self, is_sub_delim, is_unreserved};
|
||||
|
||||
/// The longest address accepted, in octets.
|
||||
pub const MAX_ADDRESS_LEN: usize = 8 * 1024;
|
||||
|
||||
/// An address scheme this crate can use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Scheme {
|
||||
/// `http` (RFC 9110 §4.2.1); only usable towards a loopback host.
|
||||
Http,
|
||||
/// `https` (RFC 9110 §4.2.2).
|
||||
Https,
|
||||
}
|
||||
|
||||
impl Scheme {
|
||||
/// The scheme's name, lower case.
|
||||
pub fn as_str(self) -> &'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<u16>,
|
||||
path_start: usize,
|
||||
query_start: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<u16>,
|
||||
}
|
||||
|
||||
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<Parts<'_>, 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<Option<u16>, 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<Self, AddressError> {
|
||||
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<Self, AddressError> {
|
||||
let address = Self::parse(input)?;
|
||||
address.require_secure_transport()?;
|
||||
Ok(address)
|
||||
}
|
||||
|
||||
fn assemble(scheme: Scheme, host: Host, port: Option<u16>, path: &str, query: Option<&str>) -> Result<Self, AddressError> {
|
||||
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<Self, AddressError> {
|
||||
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<Self, AddressError> {
|
||||
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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Value, Unusable> {
|
||||
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<RequestProblem>,
|
||||
status: Option<u16>,
|
||||
limit: Option<LimitName>,
|
||||
text: Option<String>,
|
||||
names_bad_token: bool,
|
||||
}
|
||||
|
||||
fn jmap_problem(kind: &str) -> Option<RequestProblem> {
|
||||
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<Problem> {
|
||||
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<String, Value> = 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<Excerpt> {
|
||||
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;
|
||||
@@ -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"<html>".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"<h1>Bad gateway</h1>".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());
|
||||
}
|
||||
@@ -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<HttpRequest, Error> {
|
||||
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<Session, Error> {
|
||||
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<E: HttpExchange>(exchange: &E, address: &Address, credentials: &Credentials) -> Result<Session, Error> {
|
||||
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<Self, Error> {
|
||||
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<Self, Error> {
|
||||
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<Address, Error> {
|
||||
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<Address, Error> {
|
||||
Ok(self.session.upload_template().expand(&[("accountId", self.account.as_str())])?)
|
||||
}
|
||||
|
||||
/// The request that fetches the session again.
|
||||
pub fn session_request(&self) -> Result<HttpRequest, Error> {
|
||||
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<HttpRequest, Error> {
|
||||
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<ApiResponse, Error> {
|
||||
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<HttpRequest, Error> {
|
||||
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<Vec<u8>, 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<E: HttpExchange>(&self, exchange: &E, request: &ApiRequest) -> Result<ApiResponse, Error> {
|
||||
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<E: HttpExchange>(
|
||||
&self,
|
||||
exchange: &E,
|
||||
blob: &Id,
|
||||
media_type: &str,
|
||||
name: &str,
|
||||
max_bytes: Option<usize>,
|
||||
) -> Result<Vec<u8>, 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<E: HttpExchange>(&self, exchange: &E) -> Result<Session, Error> {
|
||||
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;
|
||||
@@ -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<HttpResponse, TransportError>, 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"<html>".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: 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));
|
||||
}
|
||||
@@ -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("<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Self, CredentialsError> {
|
||||
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<Self, CredentialsError> {
|
||||
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({}, <redacted>)", self.scheme_name())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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, <redacted>)");
|
||||
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]");
|
||||
}
|
||||
@@ -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<Address, AddressError> {
|
||||
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;
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<RetryAfter>,
|
||||
},
|
||||
/// 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<LimitName>,
|
||||
},
|
||||
/// The server refuses access to this resource (403).
|
||||
Forbidden {
|
||||
/// What the server said, cleaned.
|
||||
detail: Option<Excerpt>,
|
||||
},
|
||||
/// 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<RequestProblem>,
|
||||
/// What the server said, cleaned.
|
||||
detail: Option<Excerpt>,
|
||||
},
|
||||
/// 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<Excerpt>,
|
||||
},
|
||||
/// 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<TransportError> 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<AddressError> for Error {
|
||||
fn from(error: AddressError) -> Self {
|
||||
Self::Address(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TemplateError> for Error {
|
||||
fn from(error: TemplateError) -> Self {
|
||||
Self::Template(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CredentialsError> for Error {
|
||||
fn from(error: CredentialsError) -> Self {
|
||||
Self::Credentials(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IdError> for Error {
|
||||
fn from(error: IdError) -> Self {
|
||||
Self::Id(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BuildError> for Error {
|
||||
fn from(error: BuildError) -> Self {
|
||||
Self::Build(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Unusable> for Error {
|
||||
fn from(why: Unusable) -> Self {
|
||||
Self::Unusable(why)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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), "<script>"] {
|
||||
assert_eq!(LimitName::new(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Requirement 2.4: messages are the crate's own and one line.
|
||||
#[test]
|
||||
fn messages() {
|
||||
let busy = Error::Busy { retry_after: Some(RetryAfter::Delay(Duration::from_secs(30))) };
|
||||
assert_eq!(busy.to_string(), "the server is busy and asks to wait 30 s before the next attempt");
|
||||
assert_eq!(Error::from(TransportError::Overloaded), Error::Busy { retry_after: None });
|
||||
assert_eq!(Error::from(TransportError::TimedOut), Error::Transport(TransportError::TimedOut));
|
||||
assert_eq!(Error::from(TransportError::BodyTooLarge), Error::Unusable(Unusable::BodyTooLarge));
|
||||
// The cause and the remedy, not a stock phrase.
|
||||
let refused = Error::CredentialsRefused.to_string();
|
||||
assert!(refused.contains("user name and password") && refused.contains("sign in again"));
|
||||
let long = Error::Address(AddressError::TooLong).to_string();
|
||||
assert!(long.contains("8 KiB") && long.contains("pasted"));
|
||||
for error in [
|
||||
Error::CredentialsRefused,
|
||||
Error::NotFound,
|
||||
Error::NoMailAccount,
|
||||
Error::Address(AddressError::UserInfo),
|
||||
Error::Transport(TransportError::Other),
|
||||
Error::Build(BuildError::TooManyCalls { limit: 16 }),
|
||||
] {
|
||||
let text = error.to_string();
|
||||
assert!(!text.contains('\n') && text.len() > 40, "{text}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Hosts of `http` and `https` addresses: RFC 3986 §3.2.2, read with the WHATWG URL Standard's host rules
|
||||
//! (§3.5) where RFC 3986 leaves room.
|
||||
//!
|
||||
//! What is accepted is deliberately the part on which both documents and DNS agree:
|
||||
//!
|
||||
//! * a registered name, percent-decoded, in ASCII, lower-cased, made of letters, digits, `-`, `_` and dots,
|
||||
//! with labels of 1 to 63 octets and at most 253 octets in all (one final dot allowed);
|
||||
//! * an IPv4 address written exactly as `std::net::Ipv4Addr` writes it: four decimal numbers without leading
|
||||
//! zeros (RFC 3986 `IPv4address`);
|
||||
//! * an IPv6 address in brackets, read by `std::net::Ipv6Addr` (RFC 3986 `IPv6address`), without zone
|
||||
//! identifiers, without `IPvFuture`, and with an embedded IPv4 address only as its last 32 bits in plain
|
||||
//! dotted form.
|
||||
//!
|
||||
//! A name whose last label is numeric (the WHATWG "ends in a number" test) but which is not a plain dotted
|
||||
//! IPv4 address, such as `127.1`, `0x7f.0.0.1` or `2130706433`, is refused: the WHATWG parser reads those
|
||||
//! as IPv4 addresses while RFC 3986 reads them as names, and RFC 3986 §7.4 warns about exactly this.
|
||||
|
||||
use std::fmt;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use crate::address::AddressError;
|
||||
use crate::percent;
|
||||
|
||||
/// The longest registered name, in octets, without a final dot (the DNS limit; RFC 3986 §3.2.2 notes that
|
||||
/// a registered name meant for DNS follows DNS syntax).
|
||||
const MAX_NAME_LEN: usize = 253;
|
||||
/// The longest label of a registered name, in octets.
|
||||
const MAX_LABEL_LEN: usize = 63;
|
||||
/// The longest text accepted between the brackets of an IPv6 literal: eight groups of four digits and seven
|
||||
/// colons are 39 characters; a trailing dotted IPv4 address adds at most 6 more.
|
||||
const MAX_IPV6_TEXT_LEN: usize = 45;
|
||||
|
||||
/// How close to this computer a host is, as far as its text tells (no name is resolved).
|
||||
///
|
||||
/// The order matters: [`HostScope::Public`] < [`HostScope::PrivateNetwork`] < [`HostScope::ThisComputer`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum HostScope {
|
||||
/// Any registered name other than the `localhost` family, and every other IP address.
|
||||
Public,
|
||||
/// A private, shared or link-local network: IPv4 `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`,
|
||||
/// `100.64.0.0/10` (RFC 6598), `169.254.0.0/16`; IPv6 `fc00::/7`, `fe80::/10`, `fec0::/10`.
|
||||
PrivateNetwork,
|
||||
/// This computer: `localhost` and names under it (with or without a final dot), `127.0.0.0/8`,
|
||||
/// `0.0.0.0/8`, `::1`, `::`, and those IPv4 addresses mapped or embedded in IPv6 (`::ffff:0:0/96`,
|
||||
/// `::/96`, `64:ff9b::/96`).
|
||||
ThisComputer,
|
||||
}
|
||||
|
||||
fn ipv4_scope(address: Ipv4Addr) -> HostScope {
|
||||
let [first, second, _, _] = address.octets();
|
||||
if address.is_loopback() || first == 0 {
|
||||
HostScope::ThisComputer
|
||||
} else if address.is_private() || address.is_link_local() || (first == 100 && (64..=127).contains(&second)) {
|
||||
HostScope::PrivateNetwork
|
||||
} else {
|
||||
HostScope::Public
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv6_scope(address: Ipv6Addr) -> HostScope {
|
||||
// `to_ipv4` covers ::, ::1, the IPv4-compatible ::/96 and the IPv4-mapped ::ffff:0:0/96 forms; :: and ::1
|
||||
// come out as 0.0.0.0 and 0.0.0.1, both on this computer.
|
||||
if let Some(embedded) = address.to_ipv4() {
|
||||
return ipv4_scope(embedded);
|
||||
}
|
||||
let [s0, s1, s2, s3, s4, s5, s6, s7] = address.segments();
|
||||
if [s0, s1, s2, s3, s4, s5] == [0x0064, 0xFF9B, 0, 0, 0, 0] {
|
||||
// The NAT64 well-known prefix (RFC 6052 §2.1) carries an IPv4 address in its last 32 bits.
|
||||
let [a, b] = s6.to_be_bytes();
|
||||
let [c, d] = s7.to_be_bytes();
|
||||
return ipv4_scope(Ipv4Addr::new(a, b, c, d));
|
||||
}
|
||||
// Unique local fc00::/7, link-local fe80::/10 and the deprecated site-local fec0::/10.
|
||||
if address.is_unique_local() || address.is_unicast_link_local() || s0 & 0xFFC0 == 0xFEC0 { HostScope::PrivateNetwork } else { HostScope::Public }
|
||||
}
|
||||
|
||||
/// The host of an address.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Host {
|
||||
/// A registered name, lower-case ASCII (`example.com`).
|
||||
Name(String),
|
||||
/// An IPv4 address.
|
||||
Ipv4(Ipv4Addr),
|
||||
/// An IPv6 address.
|
||||
Ipv6(Ipv6Addr),
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Reads the host part of an authority (RFC 3986 §3.2.2), brackets included for IPv6.
|
||||
pub(crate) fn parse(input: &str) -> Result<Self, AddressError> {
|
||||
if input.is_empty() {
|
||||
return Err(AddressError::EmptyHost);
|
||||
}
|
||||
if let Some(inner) = input.strip_prefix('[') {
|
||||
let inner = inner.strip_suffix(']').ok_or(AddressError::InvalidIpv6)?;
|
||||
return ipv6_between_brackets(inner).map(Self::Ipv6);
|
||||
}
|
||||
let decoded = percent::decode_bytes(input).map_err(|_| AddressError::InvalidHost)?;
|
||||
if decoded.iter().any(|b| !b.is_ascii()) {
|
||||
return Err(AddressError::InternationalHost);
|
||||
}
|
||||
let name = String::from_utf8(decoded).map_err(|_| AddressError::InvalidHost)?.to_ascii_lowercase();
|
||||
if name.is_empty() {
|
||||
return Err(AddressError::EmptyHost);
|
||||
}
|
||||
if !name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) {
|
||||
return Err(AddressError::InvalidHost);
|
||||
}
|
||||
if last_label_is_numeric(&name) {
|
||||
return plain_dotted_ipv4(&name).map(Self::Ipv4).ok_or(AddressError::AmbiguousNumericHost);
|
||||
}
|
||||
check_dns_lengths(&name)?;
|
||||
Ok(Self::Name(name))
|
||||
}
|
||||
|
||||
/// Whether this is a loopback host in the sense of the transport rule: the name `localhost` exactly, an
|
||||
/// IPv4 address in `127.0.0.0/8`, or the IPv6 address `::1`.
|
||||
///
|
||||
/// `sub.localhost`, `localhost.` and IPv4-mapped IPv6 addresses are not loopback here.
|
||||
pub fn is_loopback(&self) -> bool {
|
||||
match self {
|
||||
Self::Name(name) => name == "localhost",
|
||||
Self::Ipv4(address) => address.is_loopback(),
|
||||
Self::Ipv6(address) => address.is_loopback(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How close to this computer the host is ([`HostScope`]). Broader than [`Host::is_loopback`]: it serves
|
||||
/// to stop a remote server from sending the program towards this computer or its network, not to allow
|
||||
/// plain `http`.
|
||||
pub fn scope(&self) -> HostScope {
|
||||
match self {
|
||||
Self::Name(name) => {
|
||||
let name = name.strip_suffix('.').unwrap_or(name);
|
||||
if name == "localhost" || name.ends_with(".localhost") { HostScope::ThisComputer } else { HostScope::Public }
|
||||
}
|
||||
Self::Ipv4(address) => ipv4_scope(*address),
|
||||
Self::Ipv6(address) => ipv6_scope(*address),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Host {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Name(name) => f.write_str(name),
|
||||
Self::Ipv4(address) => write!(f, "{address}"),
|
||||
Self::Ipv6(address) => {
|
||||
f.write_str("[")?;
|
||||
write_ipv6_in_hex(f, address)?;
|
||||
f.write_str("]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Host {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a name would be read as a number by the WHATWG host parser ("ends in a number", §3.5): its last
|
||||
/// label, a final dot aside, is all decimal digits or `0x` followed by hex digits.
|
||||
fn last_label_is_numeric(name: &str) -> bool {
|
||||
let trimmed = name.strip_suffix('.').unwrap_or(name);
|
||||
let last = trimmed.rsplit('.').next().unwrap_or("");
|
||||
if last.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if last.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
match last.strip_prefix("0x").or_else(|| last.strip_prefix("0X")) {
|
||||
Some(hex) => hex.bytes().all(|b| b.is_ascii_hexdigit()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An IPv4 host, accepted only when the text is exactly the address's own dotted form: `std::net` reads it,
|
||||
/// and writing it back must give the same text. That refuses leading zeros, a final dot, and every shortened,
|
||||
/// octal or hexadecimal spelling (RFC 3986 §3.2.2 `dec-octet`, §7.4).
|
||||
fn plain_dotted_ipv4(text: &str) -> Option<Ipv4Addr> {
|
||||
let address: Ipv4Addr = text.parse().ok()?;
|
||||
(address.to_string() == text).then_some(address)
|
||||
}
|
||||
|
||||
/// An IPv6 literal, the text between the brackets, read by `std::net` once the forms this crate refuses are
|
||||
/// set aside: zone identifiers (RFC 6874's `%25…`, which the WHATWG parser refuses too), `IPvFuture`
|
||||
/// (`v1.…`), and an embedded IPv4 part that is not the last 32 bits in plain dotted form.
|
||||
fn ipv6_between_brackets(text: &str) -> Result<Ipv6Addr, AddressError> {
|
||||
if text.is_empty() || text.len() > MAX_IPV6_TEXT_LEN || text.contains('%') || text.starts_with(['v', 'V']) {
|
||||
return Err(AddressError::InvalidIpv6);
|
||||
}
|
||||
if text.contains('.') {
|
||||
let (head, dotted) = text.rsplit_once(':').ok_or(AddressError::InvalidIpv6)?;
|
||||
if head.contains('.') || plain_dotted_ipv4(dotted).is_none() {
|
||||
return Err(AddressError::InvalidIpv6);
|
||||
}
|
||||
}
|
||||
text.parse::<Ipv6Addr>().map_err(|_| AddressError::InvalidIpv6)
|
||||
}
|
||||
|
||||
fn check_dns_lengths(name: &str) -> Result<(), AddressError> {
|
||||
let body = name.strip_suffix('.').unwrap_or(name);
|
||||
if body.is_empty() || body.len() > MAX_NAME_LEN {
|
||||
return Err(AddressError::InvalidHost);
|
||||
}
|
||||
if body.split('.').any(|label| label.is_empty() || label.len() > MAX_LABEL_LEN) {
|
||||
return Err(AddressError::InvalidHost);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes an IPv6 address in the hexadecimal form of RFC 5952 §4, as the WHATWG IPv6 serializer (§3.6) does:
|
||||
/// lower-case hex without leading zeros, the first longest run of two or more zero groups written as `::`,
|
||||
/// and no dotted IPv4 tail (`std`'s `Display` writes IPv4-mapped addresses with one).
|
||||
fn write_ipv6_in_hex(f: &mut fmt::Formatter<'_>, address: &Ipv6Addr) -> fmt::Result {
|
||||
let groups = address.segments();
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
let mut run_start: Option<usize> = None;
|
||||
for (index, group) in groups.iter().enumerate() {
|
||||
if *group == 0 {
|
||||
let start = *run_start.get_or_insert(index);
|
||||
let len = index.saturating_sub(start).saturating_add(1);
|
||||
if len >= 2 && best.is_none_or(|(_, best_len)| len > best_len) {
|
||||
best = Some((start, len));
|
||||
}
|
||||
} else {
|
||||
run_start = None;
|
||||
}
|
||||
}
|
||||
let mut index = 0usize;
|
||||
let mut need_colon = false;
|
||||
while let Some(group) = groups.get(index) {
|
||||
if let Some((start, len)) = best
|
||||
&& index == start
|
||||
{
|
||||
f.write_str("::")?;
|
||||
index = index.saturating_add(len);
|
||||
need_colon = false;
|
||||
continue;
|
||||
}
|
||||
if need_colon {
|
||||
f.write_str(":")?;
|
||||
}
|
||||
write!(f, "{group:x}")?;
|
||||
need_colon = true;
|
||||
index = index.saturating_add(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,135 @@
|
||||
use super::*;
|
||||
|
||||
fn shown(input: &str) -> String {
|
||||
Host::parse(input).unwrap().to_string()
|
||||
}
|
||||
|
||||
// RFC 3986 §3.2.2 and §6.2.2.1: registered names are case-insensitive and normalised to lower case.
|
||||
#[test]
|
||||
fn names_are_lower_cased() {
|
||||
assert_eq!(shown("Example.COM"), "example.com");
|
||||
assert_eq!(shown("mail.example.org."), "mail.example.org.");
|
||||
assert_eq!(shown("under_score.example.net"), "under_score.example.net");
|
||||
}
|
||||
|
||||
// RFC 3986 §3.2.2 (pct-encoded in reg-name) and WHATWG URL §3.5: the host is percent-decoded first.
|
||||
#[test]
|
||||
fn names_are_percent_decoded() {
|
||||
assert_eq!(shown("ex%61mple.com"), "example.com");
|
||||
assert_eq!(Host::parse("ex%20ample.com"), Err(AddressError::InvalidHost));
|
||||
assert_eq!(Host::parse("ex%2"), Err(AddressError::InvalidHost));
|
||||
}
|
||||
|
||||
// WHATWG URL §3.2 (forbidden domain code points) and §3.3: what DNS cannot carry is refused, and a name
|
||||
// outside ASCII needs IDNA processing, which this crate does not do.
|
||||
#[test]
|
||||
fn refuses_bad_names() {
|
||||
for bad in ["exa mple.com", "exa*mple.com", "example..com", ".example.com", "a%2fb"] {
|
||||
assert!(Host::parse(bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(Host::parse("bücher.example.com"), Err(AddressError::InternationalHost));
|
||||
assert_eq!(Host::parse("b%C3%BCcher.example.com"), Err(AddressError::InternationalHost));
|
||||
let long_label = format!("{}.example.com", "a".repeat(64));
|
||||
assert_eq!(Host::parse(&long_label), Err(AddressError::InvalidHost));
|
||||
}
|
||||
|
||||
// RFC 3986 §3.2.2 IPv4address, and WHATWG URL §3.5 "ends in a number": only the plain dotted form passes.
|
||||
#[test]
|
||||
fn ipv4_only_in_plain_form() {
|
||||
assert_eq!(Host::parse("127.0.0.1").unwrap(), Host::Ipv4(Ipv4Addr::LOCALHOST));
|
||||
assert_eq!(shown("192.0.2.255"), "192.0.2.255");
|
||||
for ambiguous in ["127.1", "0x7f.0.0.1", "2130706433", "127.0.0.01", "127.0.0.1.", "256.0.0.1", "1.2.3.4.5"] {
|
||||
assert_eq!(Host::parse(ambiguous), Err(AddressError::AmbiguousNumericHost), "{ambiguous}");
|
||||
}
|
||||
assert!(Host::parse("example.123").is_err());
|
||||
assert_eq!(shown("v4.example.com"), "v4.example.com");
|
||||
}
|
||||
|
||||
// RFC 3986 §3.2.2 IP-literal read by std::net, WHATWG URL §3.5 (no zones) and §3.6 (hex output, RFC 5952 §4).
|
||||
#[test]
|
||||
fn ipv6_forms() {
|
||||
assert_eq!(shown("[::1]"), "[::1]");
|
||||
assert_eq!(shown("[0:0:0:0:0:0:0:1]"), "[::1]");
|
||||
assert_eq!(shown("[2001:DB8::0:1]"), "[2001:db8::1]");
|
||||
assert_eq!(shown("[2001:db8:0:0:1:0:0:1]"), "[2001:db8::1:0:0:1]");
|
||||
assert_eq!(shown("[::ffff:192.0.2.1]"), "[::ffff:c000:201]");
|
||||
assert_eq!(shown("[::]"), "[::]");
|
||||
assert_eq!(shown("[2001:db8:3:4:5:6:7:8]"), "[2001:db8:3:4:5:6:7:8]");
|
||||
assert_eq!(shown("[1:2:3:4:5:6:192.0.2.1]"), "[1:2:3:4:5:6:c000:201]");
|
||||
assert_eq!(shown("[2001:db8:0:1:1:1:1:1]"), "[2001:db8:0:1:1:1:1:1]");
|
||||
assert_eq!(shown("[1:0:0:2:0:0:0:3]"), "[1:0:0:2::3]");
|
||||
for bad in [
|
||||
"[]",
|
||||
"[:::1]",
|
||||
"[1::2::3]",
|
||||
"[12345::]",
|
||||
"[1:2:3:4:5:6:7]",
|
||||
"[1:2:3:4:5:6:7:8:9]",
|
||||
"[::1%25eth0]",
|
||||
"[v1.abc]",
|
||||
"[::1",
|
||||
"[1:2:3:4:5:6:7::8:9]",
|
||||
"[::1.2.3]",
|
||||
"[1.2.3.4::]",
|
||||
"[::01.2.3.4]",
|
||||
"[::1.2.3.4:5]",
|
||||
"[1.2.3.4]",
|
||||
"[fe80::1%eth0]",
|
||||
"[V1.abc]",
|
||||
"[::ffff:1.2.3.4.]",
|
||||
] {
|
||||
assert_eq!(Host::parse(bad), Err(AddressError::InvalidIpv6), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 6890 (special-purpose registries), RFC 1918, RFC 6598, RFC 4193, RFC 4291 §2.5.5–§2.5.6, RFC 6052
|
||||
// §2.1, RFC 6761 §6.3 (localhost names): how close to this computer a host is.
|
||||
#[test]
|
||||
fn scopes() {
|
||||
let scope = |text: &str| Host::parse(text).unwrap().scope();
|
||||
for host in [
|
||||
"localhost",
|
||||
"localhost.",
|
||||
"x.localhost",
|
||||
"x.localhost.",
|
||||
"127.0.0.1",
|
||||
"0.0.0.0",
|
||||
"[::1]",
|
||||
"[::]",
|
||||
"[::ffff:127.0.0.1]",
|
||||
"[::ffff:7f00:1]",
|
||||
"[::127.0.0.1]",
|
||||
"[64:ff9b::127.0.0.1]",
|
||||
"[::ffff:0.0.0.0]",
|
||||
] {
|
||||
assert_eq!(scope(host), HostScope::ThisComputer, "{host}");
|
||||
}
|
||||
for host in [
|
||||
"10.1.2.3",
|
||||
"172.16.0.1",
|
||||
"172.31.255.255",
|
||||
"192.168.1.1",
|
||||
"169.254.0.1",
|
||||
"100.64.0.1",
|
||||
"[fe80::1]",
|
||||
"[fd00::1]",
|
||||
"[fc00::1]",
|
||||
"[::ffff:192.168.1.1]",
|
||||
] {
|
||||
assert_eq!(scope(host), HostScope::PrivateNetwork, "{host}");
|
||||
}
|
||||
for host in ["example.com", "192.0.2.1", "172.32.0.1", "100.128.0.1", "[2001:db8::1]", "localhost.example.com"] {
|
||||
assert_eq!(scope(host), HostScope::Public, "{host}");
|
||||
}
|
||||
}
|
||||
|
||||
// Requirement 1.2: the loopback hosts are `localhost` exactly, 127.0.0.0/8 and ::1.
|
||||
#[test]
|
||||
fn loopback_hosts() {
|
||||
for host in ["localhost", "LOCALHOST", "127.0.0.1", "127.255.0.9", "[::1]", "[0::1]"] {
|
||||
assert!(Host::parse(host).unwrap().is_loopback(), "{host}");
|
||||
}
|
||||
for host in ["sub.localhost", "localhost.", "localhost.example.com", "192.0.2.1", "[::ffff:127.0.0.1]", "[2001:db8::2]"] {
|
||||
assert!(!Host::parse(host).unwrap().is_loopback(), "{host}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Hostile input on every reader (requirement 4.2, in the absence of a fuzzer in CI): deterministic
|
||||
//! pseudo-random bytes and mutations of valid samples must never panic, and what is accepted must keep the
|
||||
//! crate's promises (normal form is stable, the transport rule holds, excerpts are clean and short).
|
||||
|
||||
use crate::address::Address;
|
||||
use crate::discovery::session_address;
|
||||
use crate::redact::{EXCERPT_MAX_CHARS, Redactor};
|
||||
use crate::response::ApiResponse;
|
||||
use crate::retry::{RetryAfter, UnixTime};
|
||||
use crate::session::Session;
|
||||
use crate::session::tests::{EXAMPLE, session_address as example_session_address};
|
||||
use crate::template::AddressTemplate;
|
||||
|
||||
/// xorshift64*: a small deterministic generator, good enough to stir bytes.
|
||||
struct Stir(u64);
|
||||
|
||||
impl Stir {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
const TOKENS: &[&str] = &[
|
||||
"%",
|
||||
"%2e",
|
||||
"%2E%2e",
|
||||
"%00",
|
||||
"%ff",
|
||||
"..",
|
||||
"/./",
|
||||
"/../",
|
||||
"//",
|
||||
"\\",
|
||||
"@",
|
||||
":",
|
||||
"::",
|
||||
"[",
|
||||
"]",
|
||||
"[::1]",
|
||||
"?",
|
||||
"#",
|
||||
"{",
|
||||
"}",
|
||||
"{a}",
|
||||
"\u{0}",
|
||||
"\n",
|
||||
" ",
|
||||
"é",
|
||||
"\u{202e}",
|
||||
"http://",
|
||||
"https://",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"0x7f",
|
||||
"65536",
|
||||
"\"",
|
||||
"[]",
|
||||
"{}",
|
||||
"null",
|
||||
"1e999",
|
||||
"-1",
|
||||
"[[[[",
|
||||
"]]]]",
|
||||
",",
|
||||
"\"error\"",
|
||||
"\"methodResponses\"",
|
||||
"\"sessionState\"",
|
||||
"\"apiUrl\"",
|
||||
];
|
||||
|
||||
fn mutate(stir: &mut Stir, seed: &[u8]) -> Vec<u8> {
|
||||
let mut data = seed.to_vec();
|
||||
for _ in 0..=stir.below(4) {
|
||||
match stir.below(6) {
|
||||
0 if !data.is_empty() => {
|
||||
let at = stir.below(data.len());
|
||||
data[at] = (stir.next() & 0xFF) as u8;
|
||||
}
|
||||
1 => {
|
||||
let at = stir.below(data.len() + 1);
|
||||
let token = TOKENS[stir.below(TOKENS.len())].as_bytes();
|
||||
data.splice(at..at, token.iter().copied());
|
||||
}
|
||||
2 if !data.is_empty() => {
|
||||
let at = stir.below(data.len());
|
||||
data.truncate(at);
|
||||
}
|
||||
3 if !data.is_empty() => {
|
||||
let start = stir.below(data.len());
|
||||
let end = (start + stir.below(16)).min(data.len());
|
||||
data.drain(start..end);
|
||||
}
|
||||
4 if !data.is_empty() => {
|
||||
let start = stir.below(data.len());
|
||||
let end = (start + stir.below(32)).min(data.len());
|
||||
let copy: Vec<u8> = data[start..end].to_vec();
|
||||
data.splice(start..start, copy);
|
||||
}
|
||||
_ => {
|
||||
let len = stir.below(24);
|
||||
data.extend((0..len).map(|_| (stir.next() & 0xFF) as u8));
|
||||
}
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn inputs(seeds: &[&[u8]], rounds: usize, salt: u64) -> Vec<Vec<u8>> {
|
||||
let mut stir = Stir(0x9E37_79B9_7F4A_7C15 ^ salt);
|
||||
(0..rounds)
|
||||
.map(|_| {
|
||||
let seed = seeds[stir.below(seeds.len())];
|
||||
mutate(&mut stir, seed)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const ADDRESS_SEEDS: &[&[u8]] = &[
|
||||
b"https://jmap.example.com/.well-known/jmap",
|
||||
b"http://[::1]:8080/a/b?c=d#e",
|
||||
b"https://EXAMPLE.org:443/%7e/./../x?y=%2f",
|
||||
b"http://127.0.0.1/",
|
||||
b"example.net/jmap",
|
||||
b"https://ex%61mple.com:00080//a//b",
|
||||
];
|
||||
|
||||
// RFC 3986 §6.2.2: whatever parses re-parses to itself; diagnostics show the origin only.
|
||||
#[test]
|
||||
fn address_parser_survives_hostile_input() {
|
||||
let base = Address::parse("https://jmap.example.com/a/b/c").unwrap();
|
||||
for data in inputs(ADDRESS_SEEDS, 20_000, 1) {
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
if let Ok(address) = Address::parse(&text) {
|
||||
assert_eq!(Address::parse(address.as_str()).as_ref(), Ok(&address), "{text:?}");
|
||||
assert!(address.as_str().bytes().all(|b| (0x21..=0x7E).contains(&b)));
|
||||
assert_eq!(format!("{address:?}"), format!("Address({})", address.origin()));
|
||||
}
|
||||
if let Ok(resolved) = base.resolve(&text) {
|
||||
assert_eq!(Address::parse(resolved.as_str()).as_ref(), Ok(&resolved), "{text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Requirement 1.6 and 1.2: whatever the loose form accepts satisfies the transport rule.
|
||||
#[test]
|
||||
fn loose_session_address_survives_hostile_input() {
|
||||
for data in inputs(ADDRESS_SEEDS, 20_000, 2) {
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
if let Ok(address) = session_address(&text) {
|
||||
assert!(address.uses_secure_transport(), "{text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8620 §2: the session reader never panics on mutated sessions.
|
||||
#[test]
|
||||
fn session_parser_survives_hostile_input() {
|
||||
let address = example_session_address();
|
||||
let seeds: &[&[u8]] = &[EXAMPLE.as_bytes(), b"{\"capabilities\":{}}", b"{}"];
|
||||
let mut accepted = 0usize;
|
||||
for data in inputs(seeds, 5_000, 3) {
|
||||
if let Ok(session) = Session::parse(&data, &address) {
|
||||
accepted += 1;
|
||||
assert!(session.api_address().uses_secure_transport());
|
||||
}
|
||||
}
|
||||
assert!(accepted > 0);
|
||||
}
|
||||
|
||||
// RFC 8620 §3.4: the response reader never panics on mutated responses.
|
||||
#[test]
|
||||
fn api_response_parser_survives_hostile_input() {
|
||||
let seeds: &[&[u8]] = &[
|
||||
br#"{"methodResponses":[["Email/get",{"list":[]},"c0"],["error",{"type":"serverFail","description":"x"},"c1"]],"sessionState":"s","createdIds":{"k":"M1"}}"#,
|
||||
br#"{"methodResponses":[],"sessionState":""}"#,
|
||||
];
|
||||
let mut redactor = Redactor::new();
|
||||
redactor.add("secret-value");
|
||||
for data in inputs(seeds, 20_000, 4) {
|
||||
let _ = ApiResponse::parse(&data, &redactor);
|
||||
}
|
||||
}
|
||||
|
||||
// Requirement 1.7: template reading and expansion never panic, and expansions keep the transport rule.
|
||||
#[test]
|
||||
fn templates_survive_hostile_input() {
|
||||
let base = example_session_address();
|
||||
let seeds: &[&[u8]] = &[b"https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}", b"/upload/{accountId}/"];
|
||||
let values = [("accountId", "A1"), ("blobId", "B1"), ("name", "n"), ("type", "t/t"), ("a", ".")];
|
||||
for data in inputs(seeds, 20_000, 5) {
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
if let Ok(template) = AddressTemplate::parse(&text, &base)
|
||||
&& let Ok(address) = template.expand(&values)
|
||||
{
|
||||
assert!(address.uses_secure_transport());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Requirement 3.5 and RFC 9110 §10.2.3: excerpts and Retry-After never panic and keep their bounds.
|
||||
#[test]
|
||||
fn text_readers_survive_hostile_input() {
|
||||
let mut redactor = Redactor::new();
|
||||
redactor.add("secret-value");
|
||||
let seeds: &[&[u8]] = &[b"Mon, 27 Jul 2009 12:28:53 GMT", b"Saturday, 29-Oct-94 19:43:31 GMT", b"120", b"secret-value in text"];
|
||||
for data in inputs(seeds, 20_000, 6) {
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
let excerpt = redactor.excerpt(&text);
|
||||
assert!(excerpt.as_str().chars().count() <= EXCERPT_MAX_CHARS + 1);
|
||||
assert!(!excerpt.as_str().chars().any(char::is_control));
|
||||
assert!(!excerpt.as_str().contains("secret-value"));
|
||||
if let Some(retry) = RetryAfter::parse(&text) {
|
||||
let _ = retry.delay_from(UnixTime::from_secs(1_248_697_733));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Descriptions of one HTTP exchange, without performing it.
|
||||
//!
|
||||
//! The crate builds an [`HttpRequest`]; the program performs it however it can (a native HTTP client, the
|
||||
//! browser's Fetch API, a test double) and describes what came back in an [`HttpResponse`]. The
|
||||
//! [`HttpExchange`] trait is the shape of that service when the crate's asynchronous helpers are used.
|
||||
//!
|
||||
//! What the program performing a request must do:
|
||||
//!
|
||||
//! * send the method, address, header fields and body exactly as given;
|
||||
//! * follow no redirect itself (or, if it must, check each target with
|
||||
//! [`Address::check_link`](crate::Address::check_link) and drop the credentials when the origin changes);
|
||||
//! * read [`HttpRequest::max_response_bytes`] *before* reading the body (it is the kind's limit, or the lower
|
||||
//! one the caller set with [`HttpRequest::with_response_limit`]), refuse at once a `Content-Length` above it,
|
||||
//! and stop reading as soon as the body passes it, reporting [`TransportError::BodyTooLarge`]. The crate
|
||||
//! refuses a longer body anyway, but only after it has been read;
|
||||
//! * copy into the response the header fields named in [`HttpResponse::FIELDS_READ`].
|
||||
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
|
||||
use crate::address::Address;
|
||||
use crate::credentials::Secret;
|
||||
|
||||
/// The most a session object may weigh, in octets.
|
||||
pub const SESSION_RESPONSE_LIMIT: usize = 4 * 1024 * 1024;
|
||||
/// The most an API response may weigh, in octets.
|
||||
pub const API_RESPONSE_LIMIT: usize = 32 * 1024 * 1024;
|
||||
/// The most a downloaded blob may weigh, in octets.
|
||||
pub const DOWNLOAD_RESPONSE_LIMIT: usize = 256 * 1024 * 1024;
|
||||
/// The most of an error response's body that is read for a problem details object, in octets.
|
||||
pub const PROBLEM_BODY_LIMIT: usize = 64 * 1024;
|
||||
/// The longest header field value the response keeps; longer ones are dropped.
|
||||
pub const MAX_FIELD_VALUE_LEN: usize = 8 * 1024;
|
||||
|
||||
/// The request method.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Method {
|
||||
/// `GET`
|
||||
Get,
|
||||
/// `POST`
|
||||
Post,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
/// The method's name.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Get => "GET",
|
||||
Self::Post => "POST",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a request is for; it decides the response size limit and what a successful answer must look like.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum RequestKind {
|
||||
/// Fetching the session object (RFC 8620 §2).
|
||||
Session,
|
||||
/// An API request (RFC 8620 §3.1).
|
||||
Api,
|
||||
/// Downloading a blob (RFC 8620 §6.2).
|
||||
Download,
|
||||
}
|
||||
|
||||
impl RequestKind {
|
||||
/// The largest response body accepted for this kind of request, in octets.
|
||||
pub fn max_response_bytes(self) -> usize {
|
||||
match self {
|
||||
Self::Session => SESSION_RESPONSE_LIMIT,
|
||||
Self::Api => API_RESPONSE_LIMIT,
|
||||
Self::Download => DOWNLOAD_RESPONSE_LIMIT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a successful answer must be `application/json`.
|
||||
pub fn expects_json(self) -> bool {
|
||||
matches!(self, Self::Session | Self::Api)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FieldValue {
|
||||
Plain(String),
|
||||
Secret(Secret),
|
||||
}
|
||||
|
||||
impl FieldValue {
|
||||
fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Plain(text) => text,
|
||||
Self::Secret(secret) => secret.expose(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request for the program to perform.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpRequest {
|
||||
method: Method,
|
||||
address: Address,
|
||||
fields: Vec<(&'static str, FieldValue)>,
|
||||
body: Vec<u8>,
|
||||
kind: RequestKind,
|
||||
max_response: usize,
|
||||
}
|
||||
|
||||
impl HttpRequest {
|
||||
pub(crate) fn new(method: Method, address: Address, kind: RequestKind, authorization: &Secret) -> Self {
|
||||
let mut fields = vec![("Authorization", FieldValue::Secret(authorization.clone()))];
|
||||
if kind.expects_json() {
|
||||
fields.push(("Accept", FieldValue::Plain("application/json".to_owned())));
|
||||
}
|
||||
Self { method, address, fields, body: Vec::new(), kind, max_response: kind.max_response_bytes() }
|
||||
}
|
||||
|
||||
/// Lowers the largest response body accepted for this request to `bytes`. A value above the kind's
|
||||
/// limit ([`RequestKind::max_response_bytes`]) is reduced to it: the limit can only go down.
|
||||
pub fn with_response_limit(mut self, bytes: usize) -> Self {
|
||||
self.max_response = bytes.min(self.kind.max_response_bytes());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_json_body(mut self, body: Vec<u8>) -> Self {
|
||||
self.fields.push(("Content-Type", FieldValue::Plain("application/json".to_owned())));
|
||||
self.body = body;
|
||||
self
|
||||
}
|
||||
|
||||
/// The method.
|
||||
pub fn method(&self) -> Method {
|
||||
self.method
|
||||
}
|
||||
|
||||
/// The target address.
|
||||
pub fn address(&self) -> &Address {
|
||||
&self.address
|
||||
}
|
||||
|
||||
/// The full target address as text, for the request line.
|
||||
pub fn url(&self) -> &str {
|
||||
self.address.as_str()
|
||||
}
|
||||
|
||||
/// The header fields to send, `Authorization` included.
|
||||
pub fn fields(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.fields.iter().map(|(name, value)| (*name, value.as_str()))
|
||||
}
|
||||
|
||||
/// The body (empty for `GET`).
|
||||
pub fn body(&self) -> &[u8] {
|
||||
&self.body
|
||||
}
|
||||
|
||||
/// What the request is for.
|
||||
pub fn kind(&self) -> RequestKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// The most body octets to read from the response: the kind's limit, or the lower one set with
|
||||
/// [`HttpRequest::with_response_limit`]. The program reads it before the body, to stop early.
|
||||
pub fn max_response_bytes(&self) -> usize {
|
||||
self.max_response
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HttpRequest {
|
||||
/// Method, origin, field names (the `Authorization` value hidden) and body length: no path, no query, no
|
||||
/// secret, no content.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let fields: Vec<String> = self
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(name, value)| match value {
|
||||
FieldValue::Plain(text) => format!("{name}: {text}"),
|
||||
FieldValue::Secret(_) => format!("{name}: <redacted>"),
|
||||
})
|
||||
.collect();
|
||||
f.debug_struct("HttpRequest")
|
||||
.field("method", &self.method)
|
||||
.field("origin", &self.address.origin())
|
||||
.field("kind", &self.kind)
|
||||
.field("fields", &fields)
|
||||
.field("body_len", &self.body.len())
|
||||
.field("max_response_bytes", &self.max_response)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// What came back, as the program describes it.
|
||||
#[derive(Clone, Default, PartialEq, Eq)]
|
||||
pub struct HttpResponse {
|
||||
status: u16,
|
||||
content_type: Option<String>,
|
||||
retry_after: Option<String>,
|
||||
location: Option<String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl HttpResponse {
|
||||
/// The header fields the crate reads; others may be left out.
|
||||
pub const FIELDS_READ: [&'static str; 3] = ["Content-Type", "Retry-After", "Location"];
|
||||
|
||||
/// A response with a status code and a body.
|
||||
pub fn new(status: u16, body: Vec<u8>) -> Self {
|
||||
Self { status, body, ..Self::default() }
|
||||
}
|
||||
|
||||
/// Adds a header field; names are matched without regard to case (RFC 9110 §5.1) and only those of
|
||||
/// [`HttpResponse::FIELDS_READ`] are kept. A value longer than [`MAX_FIELD_VALUE_LEN`] is dropped.
|
||||
/// When a field comes twice, the first value is kept.
|
||||
pub fn with_field(mut self, name: &str, value: &str) -> Self {
|
||||
self.set_field(name, value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Like [`HttpResponse::with_field`], in place.
|
||||
pub fn set_field(&mut self, name: &str, value: &str) {
|
||||
if value.len() > MAX_FIELD_VALUE_LEN {
|
||||
return;
|
||||
}
|
||||
let slot = if name.eq_ignore_ascii_case("content-type") {
|
||||
&mut self.content_type
|
||||
} else if name.eq_ignore_ascii_case("retry-after") {
|
||||
&mut self.retry_after
|
||||
} else if name.eq_ignore_ascii_case("location") {
|
||||
&mut self.location
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if slot.is_none() {
|
||||
*slot = Some(value.trim_matches([' ', '\t']).to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// The status code.
|
||||
pub fn status(&self) -> u16 {
|
||||
self.status
|
||||
}
|
||||
|
||||
/// The `Content-Type` field value.
|
||||
pub fn content_type(&self) -> Option<&str> {
|
||||
self.content_type.as_deref()
|
||||
}
|
||||
|
||||
/// The `Retry-After` field value.
|
||||
pub fn retry_after(&self) -> Option<&str> {
|
||||
self.retry_after.as_deref()
|
||||
}
|
||||
|
||||
/// The `Location` field value.
|
||||
pub fn location(&self) -> Option<&str> {
|
||||
self.location.as_deref()
|
||||
}
|
||||
|
||||
/// The body.
|
||||
pub fn body(&self) -> &[u8] {
|
||||
&self.body
|
||||
}
|
||||
|
||||
/// The body, taken out of the response.
|
||||
pub fn into_body(self) -> Vec<u8> {
|
||||
self.body
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HttpResponse {
|
||||
/// Status, content type and body length: the body may hold someone's mail.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let short = |value: &Option<String>| value.as_deref().map(|text| text.chars().take(64).collect::<String>());
|
||||
f.debug_struct("HttpResponse")
|
||||
.field("status", &self.status)
|
||||
.field("content_type", &short(&self.content_type))
|
||||
.field("retry_after", &short(&self.retry_after))
|
||||
.field("has_location", &self.location.is_some())
|
||||
.field("body_len", &self.body.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the program could not complete an exchange; no free text, so nothing private can travel in it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum TransportError {
|
||||
/// The server is overloaded at the connection level (connections refused under load, an HTTP/2
|
||||
/// `REFUSED_STREAM`, a `GOAWAY` before the request was processed, …). Classified as busy.
|
||||
Overloaded,
|
||||
/// No answer in the time the program allows.
|
||||
TimedOut,
|
||||
/// The host could not be reached (name resolution, connection).
|
||||
Unreachable,
|
||||
/// TLS failed, a certificate among the causes.
|
||||
Tls,
|
||||
/// The body was longer than [`HttpRequest::max_response_bytes`].
|
||||
BodyTooLarge,
|
||||
/// The server redirected and the program, as asked, did not follow.
|
||||
RedirectRefused,
|
||||
/// The exchange was cancelled.
|
||||
Aborted,
|
||||
/// Anything else.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl fmt::Display for TransportError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Overloaded => "the server turned the connection away because it is overloaded; try again in a little while",
|
||||
Self::TimedOut => "the server did not answer in time; check the network connection and try again",
|
||||
Self::Unreachable => "the server could not be reached; check its address and the network connection",
|
||||
Self::Tls => "no secure connection could be set up, for example because the server's certificate is invalid",
|
||||
Self::BodyTooLarge => "the answer passed the size limit set for this request and was not read further",
|
||||
Self::RedirectRefused => "the server redirected the request elsewhere and the redirect was not followed",
|
||||
Self::Aborted => "the request was cancelled before it completed",
|
||||
Self::Other => "the connection failed in a way the transport did not classify",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TransportError {}
|
||||
|
||||
/// Performs one HTTP exchange for the crate's asynchronous helpers.
|
||||
///
|
||||
/// Before reading the response body, read [`HttpRequest::max_response_bytes`] and stop as soon as the body
|
||||
/// would pass it, returning [`TransportError::BodyTooLarge`] (see the module documentation).
|
||||
///
|
||||
/// The returned future need not be `Send`, so that a browser transport can implement it; the crate's own
|
||||
/// futures are `Send` whenever the program's are.
|
||||
pub trait HttpExchange {
|
||||
/// Sends `request` and describes the response.
|
||||
fn exchange(&self, request: &HttpRequest) -> impl Future<Output = Result<HttpResponse, TransportError>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
use crate::credentials::Credentials;
|
||||
use crate::test_support::made_secret;
|
||||
|
||||
fn request() -> HttpRequest {
|
||||
let credentials = Credentials::basic("john@example.com", &made_secret("s3cret-pass")).unwrap();
|
||||
let address = Address::parse("https://jmap.example.com/api/private?token=abc").unwrap();
|
||||
HttpRequest::new(Method::Post, address, RequestKind::Api, credentials.authorization()).with_json_body(b"{\"a\":1}".to_vec())
|
||||
}
|
||||
|
||||
// RFC 8620 §3.1: an API request is a POST of application/json.
|
||||
#[test]
|
||||
fn api_request_fields() {
|
||||
let request = request();
|
||||
let fields: Vec<_> = request.fields().collect();
|
||||
assert!(fields.contains(&("Content-Type", "application/json")));
|
||||
assert!(fields.contains(&("Accept", "application/json")));
|
||||
assert!(fields.iter().any(|(name, value)| *name == "Authorization" && value.starts_with("Basic ")));
|
||||
assert_eq!(request.method().as_str(), "POST");
|
||||
assert_eq!(request.url(), "https://jmap.example.com/api/private?token=abc");
|
||||
assert_eq!(request.max_response_bytes(), API_RESPONSE_LIMIT);
|
||||
}
|
||||
|
||||
// Requirement 2.3: the debug form of a request shows no secret, no user name, no path, no body.
|
||||
#[test]
|
||||
fn request_debug_is_clean() {
|
||||
let shown = format!("{:?}", request());
|
||||
for hidden in ["s3cret", "john", "Basic am9", "private", "token=abc", "\"a\""] {
|
||||
assert!(!shown.contains(hidden), "{hidden} shows in the request's debug form");
|
||||
}
|
||||
assert!(shown.contains("Authorization: <redacted>"));
|
||||
assert!(shown.contains("https://jmap.example.com"));
|
||||
}
|
||||
|
||||
// RFC 9110 §5.1: field names are case-insensitive; only the fields the crate reads are kept.
|
||||
#[test]
|
||||
fn response_fields() {
|
||||
let response = HttpResponse::new(503, Vec::new())
|
||||
.with_field("retry-AFTER", " 120 ")
|
||||
.with_field("CONTENT-type", "application/problem+json")
|
||||
.with_field("Location", "/next")
|
||||
.with_field("Location", "/second")
|
||||
.with_field("X-Other", "ignored")
|
||||
.with_field("Content-Type", &"x".repeat(MAX_FIELD_VALUE_LEN + 1));
|
||||
assert_eq!(response.retry_after(), Some("120"));
|
||||
assert_eq!(response.content_type(), Some("application/problem+json"));
|
||||
assert_eq!(response.location(), Some("/next"));
|
||||
}
|
||||
|
||||
// Requirement 2.2: the caller may lower the limit of one request, never raise it.
|
||||
#[test]
|
||||
fn per_request_limit() {
|
||||
let request = request();
|
||||
assert_eq!(request.max_response_bytes(), API_RESPONSE_LIMIT);
|
||||
let lowered = request.clone().with_response_limit(1_000);
|
||||
assert_eq!(lowered.max_response_bytes(), 1_000);
|
||||
let raised = request.with_response_limit(usize::MAX);
|
||||
assert_eq!(raised.max_response_bytes(), API_RESPONSE_LIMIT);
|
||||
assert!(format!("{lowered:?}").contains("max_response_bytes: 1000"));
|
||||
}
|
||||
|
||||
// Requirement 2.2: the size limit is stated per kind of request.
|
||||
#[test]
|
||||
fn limits_per_kind() {
|
||||
assert_eq!(RequestKind::Session.max_response_bytes(), SESSION_RESPONSE_LIMIT);
|
||||
assert_eq!(RequestKind::Download.max_response_bytes(), DOWNLOAD_RESPONSE_LIMIT);
|
||||
assert!(!RequestKind::Download.expects_json());
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! JMAP ids (RFC 8620 §1.2).
|
||||
//!
|
||||
//! An id is 1 to [`MAX_ID_LEN`] octets of the URL-safe base64 alphabet of RFC 4648 §5 without padding:
|
||||
//! `A-Z a-z 0-9 - _`. Every id received from a server goes through [`Id::new`] before it is used as a JSON
|
||||
//! key, in an address or in a later request. The maximum is the specification's own: RFC 8620 §1.2 allows
|
||||
//! 255 octets and nothing in a mail client needs to refuse a conforming server's ids.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// The longest id accepted, in octets (RFC 8620 §1.2).
|
||||
pub const MAX_ID_LEN: usize = 255;
|
||||
|
||||
/// Why a text is not a valid id.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum IdError {
|
||||
/// The id is empty.
|
||||
Empty,
|
||||
/// The id is longer than [`MAX_ID_LEN`] octets.
|
||||
TooLong,
|
||||
/// The id holds a character outside `A-Z a-z 0-9 - _`.
|
||||
InvalidCharacter,
|
||||
}
|
||||
|
||||
impl fmt::Display for IdError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Empty => "the id is empty, but a JMAP id has 1 to 255 characters",
|
||||
Self::TooLong => "the id is longer than 255 octets, the most a JMAP id may have",
|
||||
Self::InvalidCharacter => "the id contains a character other than letters, digits, - and _, the only ones a JMAP id may hold",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for IdError {}
|
||||
|
||||
/// A checked JMAP id.
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Id(String);
|
||||
|
||||
impl Id {
|
||||
/// Checks `text` and wraps it.
|
||||
pub fn new(text: &str) -> Result<Self, IdError> {
|
||||
if text.is_empty() {
|
||||
return Err(IdError::Empty);
|
||||
}
|
||||
if text.len() > MAX_ID_LEN {
|
||||
return Err(IdError::TooLong);
|
||||
}
|
||||
if !text.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
|
||||
return Err(IdError::InvalidCharacter);
|
||||
}
|
||||
Ok(Self(text.to_owned()))
|
||||
}
|
||||
|
||||
/// The id as text.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Id {
|
||||
type Err = IdError;
|
||||
|
||||
fn from_str(text: &str) -> Result<Self, IdError> {
|
||||
Self::new(text)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Id {
|
||||
type Error = IdError;
|
||||
|
||||
fn try_from(text: &str) -> Result<Self, IdError> {
|
||||
Self::new(text)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Id {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Id {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Id {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Id({})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,26 @@
|
||||
use super::*;
|
||||
|
||||
// RFC 8620 §1.2: 1 to 255 octets of A-Z a-z 0-9 - _.
|
||||
#[test]
|
||||
fn accepts_valid_ids() {
|
||||
for text in ["a", "A13824", "msg-1023_x", "0", "-", "_", &"x".repeat(255)] {
|
||||
assert_eq!(Id::new(text).unwrap().as_str(), text);
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8620 §1.2: padding, other base64 characters, separators and controls are refused.
|
||||
#[test]
|
||||
fn refuses_invalid_ids() {
|
||||
assert_eq!(Id::new(""), Err(IdError::Empty));
|
||||
assert_eq!(Id::new(&"x".repeat(256)), Err(IdError::TooLong));
|
||||
for text in ["abc=", "a+b", "a/b", "a b", "a.b", "a\u{0}", "é", "a#b", "../x", "\"", "a\nb"] {
|
||||
assert_eq!(Id::new(text), Err(IdError::InvalidCharacter), "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8620 §1.2: ids compare as exact strings (case matters).
|
||||
#[test]
|
||||
fn case_sensitive() {
|
||||
assert_ne!(Id::new("A").unwrap(), Id::new("a").unwrap());
|
||||
assert_eq!("M1".parse::<Id>().unwrap(), Id::try_from("M1").unwrap());
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! The JMAP protocol core (RFC 8620), without sockets and without browser APIs.
|
||||
//!
|
||||
//! The crate describes HTTP requests and judges the responses; the program performs each exchange through
|
||||
//! an [`HttpExchange`] (the browser's `fetch`, an HTTP client where there are sockets, a test's script). It
|
||||
//! has no I/O of its own, no thread and no clock, so it builds for wasm32 unchanged and its futures need
|
||||
//! not be `Send`. It is written from the public specifications: RFC 3986 and the WHATWG URL Standard
|
||||
//! (addresses), RFC 6570 (address templates), RFC 8620 (the session, requests and responses, ids), RFC 9110
|
||||
//! (status codes, `Retry-After`), RFC 7617 and RFC 6750 (credentials), RFC 9457 (problem details) and
|
||||
//! RFC 8259 (JSON).
|
||||
//!
|
||||
//! - [`address`], [`host`], [`discovery`], [`template`], [`percent`]: absolute `http(s)` addresses, the
|
||||
//! transport rule (`https`, or plain `http` to `localhost`, `127.0.0.0/8` or `[::1]` only), what a person
|
||||
//! types as a session address, and the download and upload templates.
|
||||
//! - [`http`], [`classify`], [`retry`], [`credentials`]: request and response descriptions, Basic and
|
||||
//! Bearer credentials, `Retry-After`, and the judgement of a response into an [`Error`].
|
||||
//! - [`session`], [`client`], [`request`], [`response`], [`id`]: the session object, the client, requests
|
||||
//! of method calls with result references, the results of each call, and ids.
|
||||
//! - [`redact`]: what a server says, made into short, clean excerpts with every known secret taken out.
|
||||
//!
|
||||
//! What a program asks with it (`Mailbox/get`, `Email/query`…) and what it makes of the answers is the
|
||||
//! program's: the method calls' arguments and results are JSON values here.
|
||||
//!
|
||||
//! Everything a server sends is hostile: sizes, counts, nesting and the length of every string are
|
||||
//! bounded, ids are held to the JMAP alphabet ([`Id`]), and nothing indexes into what came over the wire.
|
||||
//! The credentials, the account's address and the session's stay out of error text ([`Redactor`]), and
|
||||
//! `Debug` output shows no password, token, user name, path or mail.
|
||||
|
||||
// The protocol core, under the lints it was written with: every public item documented, and outside the
|
||||
// tests no indexing and no arithmetic that can overflow.
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod address;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod classify;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod client;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod credentials;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod discovery;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod error;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod host;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod http;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod id;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod percent;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod redact;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod request;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod response;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod retry;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod session;
|
||||
#[deny(missing_docs)]
|
||||
#[cfg_attr(not(test), deny(clippy::indexing_slicing, clippy::arithmetic_side_effects))]
|
||||
pub mod template;
|
||||
|
||||
#[cfg(test)]
|
||||
mod hostile_tests;
|
||||
// The executor of the tests, and a scripted exchange. Never in a build that is not a test's.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic, clippy::unwrap_used)]
|
||||
mod test_support;
|
||||
|
||||
pub use address::{Address, AddressError, Origin, Scheme};
|
||||
pub use classify::judge_response;
|
||||
pub use client::{Client, fetch_session, read_session, session_request};
|
||||
pub use credentials::{Credentials, CredentialsError};
|
||||
pub use discovery::session_address;
|
||||
pub use error::{BuildError, Error, LimitName, RequestProblem, Unusable};
|
||||
pub use host::{Host, HostScope};
|
||||
pub use http::{HttpExchange, HttpRequest, HttpResponse, Method, RequestKind, TransportError};
|
||||
pub use id::{Id, IdError};
|
||||
pub use redact::{Excerpt, Redactor};
|
||||
pub use request::{ApiRequest, CallId, ResultReference};
|
||||
pub use response::{ApiResponse, CallFailure, MethodError, MethodErrorKind, MethodResponse};
|
||||
pub use retry::{HttpDate, MAX_RETRY_DELAY, RetryAfter, UnixTime};
|
||||
pub use session::{Account, CAPABILITY_CORE, CAPABILITY_MAIL, CoreLimits, Session};
|
||||
pub use template::{AddressTemplate, TemplateError};
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Percent-encoding and decoding (RFC 3986 §2.1).
|
||||
//!
|
||||
//! [`encode_component`] produces text that is safe in a path segment and in a query value: every octet
|
||||
//! outside the unreserved set of RFC 3986 §2.3 is written as `%XX`. This is also the encoding RFC 6570
|
||||
//! §3.2.1 asks of a simple string expansion, which is what JMAP's address templates use.
|
||||
//!
|
||||
//! [`decode`] reverses it and refuses to hand back anything that is not UTF-8; [`decode_bytes`] gives the raw
|
||||
//! octets for callers that want them. A `+` is left as it is: form encoding is not part of RFC 3986.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Why percent-decoding failed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DecodeError {
|
||||
/// A `%` was not followed by two hexadecimal digits.
|
||||
MalformedEscape,
|
||||
/// The decoded octets are not UTF-8.
|
||||
InvalidUtf8,
|
||||
}
|
||||
|
||||
impl fmt::Display for DecodeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MalformedEscape => f.write_str("a % sign is not followed by two hexadecimal digits; a literal % is written %25"),
|
||||
Self::InvalidUtf8 => f.write_str("the percent-encoded octets do not form UTF-8 text"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DecodeError {}
|
||||
|
||||
/// Whether `b` is in the unreserved set of RFC 3986 §2.3.
|
||||
pub(crate) fn is_unreserved(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~')
|
||||
}
|
||||
|
||||
/// Whether `b` is one of the sub-delimiters of RFC 3986 §2.2.
|
||||
pub(crate) fn is_sub_delim(b: u8) -> bool {
|
||||
matches!(b, b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'=')
|
||||
}
|
||||
|
||||
const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
|
||||
|
||||
fn hex_char(nibble: u8) -> char {
|
||||
char::from(HEX_UPPER.get(usize::from(nibble & 0x0F)).copied().unwrap_or(b'0'))
|
||||
}
|
||||
|
||||
/// The value of one hexadecimal digit.
|
||||
pub(crate) fn hex_value(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b.saturating_sub(b'0')),
|
||||
b'a'..=b'f' => Some(b.saturating_sub(b'a').saturating_add(10)),
|
||||
b'A'..=b'F' => Some(b.saturating_sub(b'A').saturating_add(10)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_escaped(out: &mut String, b: u8) {
|
||||
out.push('%');
|
||||
out.push(hex_char(b >> 4));
|
||||
out.push(hex_char(b));
|
||||
}
|
||||
|
||||
/// Percent-encodes `value` for a path segment or a query value.
|
||||
///
|
||||
/// Only the unreserved characters of RFC 3986 §2.3 (`A-Z a-z 0-9 - . _ ~`) are kept as they are; every other
|
||||
/// octet of the UTF-8 form, `/`, `?`, `&`, `=`, `+`, `%` and `#` included, becomes `%XX` with upper-case hex.
|
||||
pub fn encode_component(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len().saturating_mul(3));
|
||||
for &b in value.as_bytes() {
|
||||
if is_unreserved(b) {
|
||||
out.push(char::from(b));
|
||||
} else {
|
||||
push_escaped(&mut out, b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decodes every `%XX` triplet of `input` into the octet it stands for.
|
||||
pub fn decode_bytes(input: &str) -> Result<Vec<u8>, DecodeError> {
|
||||
let mut out = Vec::with_capacity(input.len());
|
||||
let mut bytes = input.bytes();
|
||||
while let Some(b) = bytes.next() {
|
||||
if b == b'%' {
|
||||
let high = bytes.next().and_then(hex_value).ok_or(DecodeError::MalformedEscape)?;
|
||||
let low = bytes.next().and_then(hex_value).ok_or(DecodeError::MalformedEscape)?;
|
||||
out.push(high.saturating_mul(16).saturating_add(low));
|
||||
} else {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decodes percent-encoding and returns the text, refusing a result that is not UTF-8.
|
||||
///
|
||||
/// Nothing is ever replaced by U+FFFD: a value that does not decode to UTF-8 is an error.
|
||||
pub fn decode(input: &str) -> Result<String, DecodeError> {
|
||||
let bytes = decode_bytes(input)?;
|
||||
String::from_utf8(bytes).map_err(|_| DecodeError::InvalidUtf8)
|
||||
}
|
||||
|
||||
/// Rewrites the escapes of an already character-checked component in their normal form (RFC 3986 §6.2.2.1 and
|
||||
/// §6.2.2.2): hex digits in upper case, and an escaped unreserved character written as itself.
|
||||
///
|
||||
/// Returns `None` when a `%` is not followed by two hex digits.
|
||||
pub(crate) fn normalise_escapes(input: &str) -> Option<String> {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut bytes = input.bytes();
|
||||
while let Some(b) = bytes.next() {
|
||||
if b == b'%' {
|
||||
let high = bytes.next().and_then(hex_value)?;
|
||||
let low = bytes.next().and_then(hex_value)?;
|
||||
let octet = high.saturating_mul(16).saturating_add(low);
|
||||
if is_unreserved(octet) {
|
||||
out.push(char::from(octet));
|
||||
} else {
|
||||
push_escaped(&mut out, octet);
|
||||
}
|
||||
} else {
|
||||
out.push(char::from(b));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,51 @@
|
||||
use super::*;
|
||||
|
||||
// RFC 3986 §2.3: unreserved characters are never encoded; everything else is.
|
||||
#[test]
|
||||
fn encodes_everything_but_unreserved() {
|
||||
assert_eq!(encode_component("AZaz09-._~"), "AZaz09-._~");
|
||||
assert_eq!(encode_component("a b/c?d&e=f+g%h#i"), "a%20b%2Fc%3Fd%26e%3Df%2Bg%25h%23i");
|
||||
assert_eq!(encode_component("application/pdf"), "application%2Fpdf");
|
||||
}
|
||||
|
||||
// RFC 3986 §2.1 and §2.5: non-ASCII text is encoded as the octets of its UTF-8 form.
|
||||
#[test]
|
||||
fn encodes_utf8_octets() {
|
||||
assert_eq!(encode_component("é"), "%C3%A9");
|
||||
assert_eq!(encode_component("Ünïcode €"), "%C3%9Cn%C3%AFcode%20%E2%82%AC");
|
||||
}
|
||||
|
||||
// RFC 3986 §2.1: decoding is the inverse; hex digits are case-insensitive.
|
||||
#[test]
|
||||
fn decodes_round_trip() {
|
||||
for text in ["", "plain", "a b/c?d", "é€😀", "%", "100%"] {
|
||||
assert_eq!(decode(&encode_component(text)).unwrap(), text);
|
||||
}
|
||||
assert_eq!(decode("%c3%a9").unwrap(), "é");
|
||||
assert_eq!(decode("a+b").unwrap(), "a+b");
|
||||
}
|
||||
|
||||
// RFC 3986 §2.1: a percent sign must start a full triplet.
|
||||
#[test]
|
||||
fn refuses_malformed_escapes() {
|
||||
assert_eq!(decode("%"), Err(DecodeError::MalformedEscape));
|
||||
assert_eq!(decode("%4"), Err(DecodeError::MalformedEscape));
|
||||
assert_eq!(decode("%zz"), Err(DecodeError::MalformedEscape));
|
||||
assert_eq!(decode("%%41"), Err(DecodeError::MalformedEscape));
|
||||
}
|
||||
|
||||
// Requirement 1.5: decoding never produces invalid UTF-8 silently.
|
||||
#[test]
|
||||
fn refuses_invalid_utf8() {
|
||||
assert_eq!(decode("%FF"), Err(DecodeError::InvalidUtf8));
|
||||
assert_eq!(decode("%C3"), Err(DecodeError::InvalidUtf8));
|
||||
assert_eq!(decode_bytes("%FF").unwrap(), vec![0xFF]);
|
||||
}
|
||||
|
||||
// RFC 3986 §6.2.2.1 and §6.2.2.2: upper-case hex, unreserved characters decoded.
|
||||
#[test]
|
||||
fn normalises_escapes() {
|
||||
assert_eq!(normalise_escapes("%7esmith").unwrap(), "~smith");
|
||||
assert_eq!(normalise_escapes("%2f%2E%41").unwrap(), "%2F.A");
|
||||
assert_eq!(normalise_escapes("%2"), None);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Making a server's text fit for an error message (requirement 3.5).
|
||||
//!
|
||||
//! An [`Excerpt`] is one line, without control or invisible characters, at most [`EXCERPT_MAX_CHARS`]
|
||||
//! characters, and with every secret the [`Redactor`] knows replaced by `[redacted]`.
|
||||
//!
|
||||
//! How it works, and why:
|
||||
//!
|
||||
//! 1. The server's text is first *normalised*: every run of white space, control characters and line or
|
||||
//! paragraph separators becomes one space, and invisible formatting characters (zero-width spaces and
|
||||
//! joiners, bidirectional controls, the byte order mark, soft hyphens, tag characters) are removed. Secrets
|
||||
//! are normalised the same way. Matching happens on the normalised text, which is also what is shown, so
|
||||
//! the cleaning cannot rebuild a secret the matching missed (`open\nsesame` is found as `open sesame`).
|
||||
//! 2. Matching ignores ASCII case and covers several written forms of each secret (as is, percent-encoded,
|
||||
//! form-encoded with `+` for spaces; the credentials add their base64 forms). Overlapping matches are
|
||||
//! merged. The search is linear in the text (Knuth–Morris–Pratt), so a hostile server cannot make it slow.
|
||||
//! 3. Secrets are replaced before anything is cut: the scanned window reaches past its end by the length of
|
||||
//! the longest secret, so a secret straddling the cut is still found whole and never shows half of itself.
|
||||
//!
|
||||
//! Two strengths of entry: [`Redactor::add`] is for secrets proper (passwords, tokens). When one is shorter
|
||||
//! than [`MIN_SECRET_LEN`] octets, replacing it would itself reveal it (every `a` gone tells that the password
|
||||
//! is `a`), so all server text is then withheld. [`Redactor::add_personal`] is for personal data and
|
||||
//! addresses (a user name, an account name, the session address); a form shorter than [`MIN_SECRET_LEN`] is
|
||||
//! then simply not looked for, so that a server cannot blank every message by naming a user `x`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::credentials::Secret;
|
||||
use crate::percent::encode_component;
|
||||
|
||||
/// The most characters an excerpt holds, not counting the final ellipsis.
|
||||
pub const EXCERPT_MAX_CHARS: usize = 160;
|
||||
/// Secrets shorter than this, in octets once normalised, cannot be replaced safely.
|
||||
pub const MIN_SECRET_LEN: usize = 3;
|
||||
/// How much normalised text is looked at, in octets; the rest is cut.
|
||||
const SCAN_WINDOW: usize = 4 * 1024;
|
||||
/// What replaces a secret.
|
||||
const MARK: &str = "[redacted]";
|
||||
/// What stands for a text that could not be shown safely.
|
||||
const WITHHELD: &str = "[server text withheld]";
|
||||
/// The number of characters of [`MARK`].
|
||||
const MARK_CHARS: usize = 10;
|
||||
|
||||
/// A short, single-line, control-free piece of server text with secrets removed.
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Excerpt(String);
|
||||
|
||||
impl Excerpt {
|
||||
/// The text.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Excerpt {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Excerpt {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Debug::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// The secrets a client knows, and the means to keep them out of messages.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Redactor {
|
||||
needles: Vec<Secret>,
|
||||
withhold: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Redactor {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Redactor({} forms)", self.needles.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters removed outright: they are invisible and could split a secret without showing.
|
||||
fn is_invisible(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{00AD}' | '\u{034F}' | '\u{061C}' | '\u{115F}' | '\u{1160}' | '\u{17B4}' | '\u{17B5}' | '\u{180B}'..='\u{180F}'
|
||||
| '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{3164}' | '\u{FE00}'..='\u{FE0F}'
|
||||
| '\u{FEFF}' | '\u{FFA0}' | '\u{FFF0}'..='\u{FFFB}' | '\u{E0000}'..='\u{E0FFF}')
|
||||
}
|
||||
|
||||
/// Characters that become a space: white space, controls (Unicode `Cc`), line and paragraph separators.
|
||||
fn is_separator(c: char) -> bool {
|
||||
c.is_whitespace() || c.is_control() || matches!(c, '\u{2028}' | '\u{2029}')
|
||||
}
|
||||
|
||||
/// Normalises `text` (see the module documentation), stopping once `limit` octets are produced. Returns the
|
||||
/// text and whether anything visible was left out.
|
||||
fn normalise(text: &str, limit: usize) -> (String, bool) {
|
||||
let mut out = String::new();
|
||||
let mut pending_space = false;
|
||||
for c in text.chars() {
|
||||
if is_invisible(c) {
|
||||
continue;
|
||||
}
|
||||
if is_separator(c) {
|
||||
pending_space = true;
|
||||
continue;
|
||||
}
|
||||
if out.len().saturating_add(c.len_utf8()).saturating_add(1) > limit {
|
||||
return (out, true);
|
||||
}
|
||||
if pending_space && !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
pending_space = false;
|
||||
out.push(c);
|
||||
}
|
||||
(out, false)
|
||||
}
|
||||
|
||||
fn floor_boundary(text: &str, index: usize) -> usize {
|
||||
let mut index = index.min(text.len());
|
||||
while index > 0 && !text.is_char_boundary(index) {
|
||||
index = index.saturating_sub(1);
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// The Knuth–Morris–Pratt failure table: for each prefix of `needle`, the length of its longest proper
|
||||
/// prefix that is also a suffix.
|
||||
fn failure_table(needle: &[u8]) -> Vec<usize> {
|
||||
let mut table = vec![0usize; needle.len()];
|
||||
let mut k = 0usize;
|
||||
for (i, &b) in needle.iter().enumerate().skip(1) {
|
||||
while k > 0 && needle.get(k) != Some(&b) {
|
||||
k = table.get(k.saturating_sub(1)).copied().unwrap_or(0);
|
||||
}
|
||||
if needle.get(k) == Some(&b) {
|
||||
k = k.saturating_add(1);
|
||||
}
|
||||
if let Some(slot) = table.get_mut(i) {
|
||||
*slot = k;
|
||||
}
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
/// Calls `found` with the start of every occurrence of `needle` in `hay`, overlapping ones included.
|
||||
fn for_each_match(hay: &[u8], needle: &[u8], mut found: impl FnMut(usize)) {
|
||||
let len = needle.len();
|
||||
if len == 0 || len > hay.len() {
|
||||
return;
|
||||
}
|
||||
let table = failure_table(needle);
|
||||
let mut k = 0usize;
|
||||
for (i, &b) in hay.iter().enumerate() {
|
||||
while k > 0 && needle.get(k) != Some(&b) {
|
||||
k = table.get(k.saturating_sub(1)).copied().unwrap_or(0);
|
||||
}
|
||||
if needle.get(k) == Some(&b) {
|
||||
k = k.saturating_add(1);
|
||||
}
|
||||
if k == len {
|
||||
found(i.saturating_add(1).saturating_sub(len));
|
||||
k = table.get(len.saturating_sub(1)).copied().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Redactor {
|
||||
/// An empty redactor.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Adds a secret proper (a password, a token) in each of its written forms.
|
||||
///
|
||||
/// A form shorter than [`MIN_SECRET_LEN`] once normalised makes every excerpt withheld.
|
||||
pub fn add(&mut self, secret: &str) {
|
||||
self.add_forms(secret, true);
|
||||
}
|
||||
|
||||
/// Adds personal data or an address (a user name, an account name, the session address) in each of its
|
||||
/// written forms. A form shorter than [`MIN_SECRET_LEN`] is not looked for.
|
||||
pub fn add_personal(&mut self, text: &str) {
|
||||
self.add_forms(text, false);
|
||||
}
|
||||
|
||||
fn add_forms(&mut self, text: &str, strong: bool) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let encoded = Secret::new(encode_component(text));
|
||||
let form_encoded = Secret::new(encoded.expose().replace("%20", "+"));
|
||||
for form in [text, encoded.expose(), form_encoded.expose()] {
|
||||
self.push(form, strong);
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, form: &str, strong: bool) {
|
||||
let normalised = Secret::new(normalise(form, usize::MAX).0);
|
||||
let needle = Secret::new(normalised.expose().to_ascii_lowercase());
|
||||
if needle.expose().len() < MIN_SECRET_LEN {
|
||||
if strong {
|
||||
self.withhold = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.needles.iter().any(|known| known.expose() == needle.expose()) {
|
||||
self.needles.push(needle);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many written forms are looked for.
|
||||
pub fn len(&self) -> usize {
|
||||
self.needles.len()
|
||||
}
|
||||
|
||||
/// Whether nothing is looked for.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.needles.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `text` holds a known secret (or excerpts are withheld altogether).
|
||||
pub fn mentions_secret(&self, text: &str) -> bool {
|
||||
self.withhold || self.excerpt(text).as_str() != normalise(text, usize::MAX).0
|
||||
}
|
||||
|
||||
/// Turns a server's text into an excerpt fit for an error message.
|
||||
pub fn excerpt(&self, text: &str) -> Excerpt {
|
||||
if self.withhold {
|
||||
return Excerpt(WITHHELD.to_owned());
|
||||
}
|
||||
let longest = self.needles.iter().map(|needle| needle.expose().len()).max().unwrap_or(0);
|
||||
let (normalised, cut_short) = normalise(text, SCAN_WINDOW.saturating_add(longest));
|
||||
let normalised = Secret::new(normalised);
|
||||
let shown = normalised.expose();
|
||||
let window_end = floor_boundary(shown, SCAN_WINDOW);
|
||||
let folded = Secret::new(shown.to_ascii_lowercase());
|
||||
// ends[i]: the furthest end of a secret starting at octet i.
|
||||
let mut ends = vec![0usize; window_end];
|
||||
for needle in &self.needles {
|
||||
let needle_len = needle.expose().len();
|
||||
for_each_match(folded.expose().as_bytes(), needle.expose().as_bytes(), |start| {
|
||||
if let Some(slot) = ends.get_mut(start) {
|
||||
*slot = (*slot).max(start.saturating_add(needle_len));
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut count = 0usize;
|
||||
let mut covered_until = 0usize;
|
||||
let mut in_mark = false;
|
||||
let mut truncated = cut_short || window_end < shown.len();
|
||||
for (index, c) in shown.get(..window_end).unwrap_or("").char_indices() {
|
||||
for end in ends.iter().take(index.saturating_add(c.len_utf8())).skip(index) {
|
||||
covered_until = covered_until.max(*end);
|
||||
}
|
||||
if index < covered_until {
|
||||
if !in_mark {
|
||||
if count.saturating_add(MARK_CHARS) > EXCERPT_MAX_CHARS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
out.push_str(MARK);
|
||||
count = count.saturating_add(MARK_CHARS);
|
||||
in_mark = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
in_mark = false;
|
||||
if count >= EXCERPT_MAX_CHARS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
out.push(c);
|
||||
count = count.saturating_add(1);
|
||||
}
|
||||
if truncated {
|
||||
let kept = out.trim_end().len();
|
||||
out.truncate(kept);
|
||||
out.push('…');
|
||||
}
|
||||
Excerpt(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,130 @@
|
||||
use super::*;
|
||||
|
||||
fn redactor(secrets: &[&str]) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for secret in secrets {
|
||||
redactor.add(secret);
|
||||
}
|
||||
redactor
|
||||
}
|
||||
|
||||
// Requirement 3.5: one line, no control characters, no invisible characters.
|
||||
#[test]
|
||||
fn one_line_no_controls() {
|
||||
let excerpt = Redactor::new().excerpt(" line one\r\nline\ttwo\u{0}\u{1b}[31m red \u{202E}evil\u{2028}end\u{200B}\u{FEFF}! ");
|
||||
assert_eq!(excerpt.as_str(), "line one line two [31m red evil end!");
|
||||
}
|
||||
|
||||
// Requirement 3.5: bounded length, with an ellipsis when cut.
|
||||
#[test]
|
||||
fn bounded_length() {
|
||||
let excerpt = Redactor::new().excerpt(&"word ".repeat(500));
|
||||
assert!(excerpt.as_str().chars().count() <= EXCERPT_MAX_CHARS + 1);
|
||||
assert!(excerpt.as_str().ends_with('…'));
|
||||
let marks = redactor(&["secret"]).excerpt(&"a secret ".repeat(100));
|
||||
assert!(marks.as_str().chars().count() <= EXCERPT_MAX_CHARS + 1);
|
||||
assert_eq!(Redactor::new().excerpt("short").as_str(), "short");
|
||||
let multibyte = "é".repeat(EXCERPT_MAX_CHARS * 2);
|
||||
assert_eq!(Redactor::new().excerpt(&multibyte).as_str().chars().count(), EXCERPT_MAX_CHARS + 1);
|
||||
}
|
||||
|
||||
// Requirement 3.5: every known secret is replaced, whatever its case or written form.
|
||||
#[test]
|
||||
fn secrets_are_replaced() {
|
||||
let mut redactor = redactor(&["s3cret-pass", "open sesame"]);
|
||||
redactor.add_personal("john@example.com");
|
||||
redactor.add_personal("https://jmap.example.com/session?t=abc");
|
||||
let excerpt = redactor.excerpt("bad password s3cret-pass for JOHN@EXAMPLE.COM (john%40example.com)");
|
||||
assert_eq!(excerpt.as_str(), "bad password [redacted] for [redacted] ([redacted])");
|
||||
let excerpt = redactor.excerpt("fetch https://jmap.example.com/session?t=abc failed");
|
||||
assert_eq!(excerpt.as_str(), "fetch [redacted] failed");
|
||||
for disguised in ["open\nsesame", "open sesame", "open\u{a0}sesame", "OPEN\tSESAME", "open+sesame", "open%20sesame", "open\u{200B} sesame"] {
|
||||
let excerpt = redactor.excerpt(&format!("got {disguised} here"));
|
||||
assert_eq!(excerpt.as_str(), "got [redacted] here", "{disguised:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Requirement 3.5: secrets are replaced before cutting, so one straddling the cut never shows in part.
|
||||
#[test]
|
||||
fn secret_across_the_cut_is_not_shown() {
|
||||
let secret = "VerySecretToken0123456789";
|
||||
let redactor = redactor(&[secret]);
|
||||
for prefix_len in 140..170 {
|
||||
let text = format!("{}{}{}", "x".repeat(prefix_len), secret, "y".repeat(50));
|
||||
let excerpt = redactor.excerpt(&text);
|
||||
for cut in 4..secret.len() {
|
||||
assert!(!excerpt.as_str().contains(&secret[..cut]), "prefix {prefix_len} shows {cut} octets");
|
||||
}
|
||||
}
|
||||
// A long secret repeated so that the scan window ends inside one of its occurrences, while the output
|
||||
// (each occurrence shrinking to "[redacted]") still reaches that far.
|
||||
let long: String = (0..900u32).map(|i| char::from(b'a' + u8::try_from(i * 7 % 26).unwrap())).collect();
|
||||
let redactor = redactor_with(&long);
|
||||
let text = format!("{long} ").repeat(20);
|
||||
let excerpt = redactor.excerpt(&text);
|
||||
for start in 0..long.len() - 8 {
|
||||
assert!(!excerpt.as_str().contains(&long[start..start + 8]), "{}", excerpt.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn redactor_with(secret: &str) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
redactor.add(secret);
|
||||
redactor
|
||||
}
|
||||
|
||||
// Requirement 3.5: overlapping secrets are covered together.
|
||||
#[test]
|
||||
fn overlapping_secrets() {
|
||||
let both = redactor(&["abcdef", "defghi"]);
|
||||
assert_eq!(both.excerpt("xx abcdefghi yy").as_str(), "xx [redacted] yy");
|
||||
let repeated = redactor(&["aaaa"]);
|
||||
assert_eq!(repeated.excerpt("baaaaab").as_str(), "b[redacted]b");
|
||||
}
|
||||
|
||||
// Requirement 3.5 (RFC 7617 §2 passwords may be short): a one- or two-octet secret cannot be hidden by
|
||||
// replacement, so the text is withheld; short personal data is simply not looked for.
|
||||
#[test]
|
||||
fn short_secrets() {
|
||||
assert_eq!(redactor(&["ab"]).excerpt("nothing to see").as_str(), WITHHELD);
|
||||
let mut personal = Redactor::new();
|
||||
personal.add_personal("x");
|
||||
assert_eq!(personal.excerpt("x marks the spot").as_str(), "x marks the spot");
|
||||
}
|
||||
|
||||
// RFC 8620 §8.5 (resource exhaustion): matching is linear, even for a periodic secret and text.
|
||||
#[test]
|
||||
fn matching_is_linear() {
|
||||
let mut redactor = Redactor::new();
|
||||
redactor.add_personal(&"a".repeat(1024));
|
||||
let text = "a".repeat(64 * 1024);
|
||||
for _ in 0..200 {
|
||||
assert_eq!(redactor.excerpt(&text).as_str(), "[redacted]…");
|
||||
}
|
||||
}
|
||||
|
||||
// Requirement 3.5: identifiers that echo a secret can be detected.
|
||||
#[test]
|
||||
fn mentions_secret() {
|
||||
let redactor = redactor(&["hunter22"]);
|
||||
assert!(redactor.mentions_secret("xhunter22x"));
|
||||
assert!(!redactor.mentions_secret("serverFail"));
|
||||
}
|
||||
|
||||
// Requirement 2.3: the redactor's debug form says nothing about its secrets.
|
||||
#[test]
|
||||
fn debug_hides_secrets() {
|
||||
let redactor = redactor(&["s3cret-pass"]);
|
||||
assert!(!format!("{redactor:?}").contains("s3cret"));
|
||||
}
|
||||
|
||||
// KMP itself, against a naive search.
|
||||
#[test]
|
||||
fn kmp_finds_every_occurrence() {
|
||||
for (hay, needle) in [("aaaaa", "aa"), ("abababab", "abab"), ("xyz", "xyzw"), ("abcabcab", "cab"), ("", "a")] {
|
||||
let mut found = Vec::new();
|
||||
for_each_match(hay.as_bytes(), needle.as_bytes(), |start| found.push(start));
|
||||
let expected: Vec<usize> = (0..hay.len()).filter(|&i| hay[i..].starts_with(needle)).collect();
|
||||
assert_eq!(found, expected, "{hay} / {needle}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Building an API request (RFC 8620 §3.3): capabilities used, method calls with call ids (§3.2), and result
|
||||
//! references between calls (§3.7).
|
||||
//!
|
||||
//! Call ids are chosen by the builder (`c0`, `c1`, …) so that they are unique within the request and valid
|
||||
//! ids. A [`ResultReference`] can only be made to a call already in the same request, which is the order
|
||||
//! RFC 8620 §3.7 requires ("a previous method call in the current request").
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::BuildError;
|
||||
use crate::id::Id;
|
||||
use crate::session::CAPABILITY_CORE;
|
||||
|
||||
/// The most method calls one request may hold, whatever the server allows.
|
||||
pub const MAX_CALLS: usize = 1024;
|
||||
/// The longest method or response name accepted.
|
||||
pub const MAX_METHOD_NAME_LEN: usize = 255;
|
||||
/// The longest result reference path accepted.
|
||||
pub const MAX_PATH_LEN: usize = 1024;
|
||||
/// The longest capability identifier accepted in `using`.
|
||||
const MAX_CAPABILITY_LEN: usize = 256;
|
||||
|
||||
/// Gives every request its own tag, so that a call id from one request is not taken for one of another.
|
||||
static NEXT_REQUEST_TAG: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// The id of a method call within a request (RFC 8620 §3.2), tied to the request that made it.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct CallId {
|
||||
text: String,
|
||||
request: u64,
|
||||
}
|
||||
|
||||
impl CallId {
|
||||
/// The id as text.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CallId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.text)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CallId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "CallId({})", self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to the result of an earlier call (RFC 8620 §3.7).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ResultReference {
|
||||
result_of: CallId,
|
||||
name: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl ResultReference {
|
||||
/// The call whose result is used.
|
||||
pub fn result_of(&self) -> &CallId {
|
||||
&self.result_of
|
||||
}
|
||||
|
||||
/// The response name required.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// The JSON Pointer into the response's arguments.
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn to_json(&self) -> Value {
|
||||
let mut object = Map::new();
|
||||
object.insert("resultOf".to_owned(), Value::String(self.result_of.text.clone()));
|
||||
object.insert("name".to_owned(), Value::String(self.name.clone()));
|
||||
object.insert("path".to_owned(), Value::String(self.path.clone()));
|
||||
Value::Object(object)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Call {
|
||||
method: String,
|
||||
arguments: Map<String, Value>,
|
||||
id: CallId,
|
||||
}
|
||||
|
||||
/// An API request under construction.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ApiRequest {
|
||||
tag: u64,
|
||||
using: Vec<String>,
|
||||
calls: Vec<Call>,
|
||||
created_ids: Option<BTreeMap<Id, Id>>,
|
||||
response_limit: Option<usize>,
|
||||
}
|
||||
|
||||
fn is_printable_name(text: &str, max: usize) -> bool {
|
||||
!text.is_empty() && text.len() <= max && text.bytes().all(|b| (0x21..=0x7E).contains(&b))
|
||||
}
|
||||
|
||||
/// RFC 6901 §3: a JSON Pointer is empty or a sequence of `/`-prefixed tokens in which `~` only appears as
|
||||
/// `~0` or `~1`. RFC 8620 §3.7 adds `*` as a token, which needs no special syntax.
|
||||
fn is_json_pointer(path: &str) -> bool {
|
||||
if path.len() > MAX_PATH_LEN || !(path.is_empty() || path.starts_with('/')) {
|
||||
return false;
|
||||
}
|
||||
let mut chars = path.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '~' && !matches!(chars.next(), Some('0' | '1')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for ApiRequest {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiRequest {
|
||||
/// An empty request using the core capability.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tag: NEXT_REQUEST_TAG.fetch_add(1, Ordering::Relaxed),
|
||||
using: vec![CAPABILITY_CORE.to_owned()],
|
||||
calls: Vec::new(),
|
||||
created_ids: None,
|
||||
response_limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a capability to `using` (once).
|
||||
pub fn using(&mut self, capability: &str) -> Result<&mut Self, BuildError> {
|
||||
if !is_printable_name(capability, MAX_CAPABILITY_LEN) {
|
||||
return Err(BuildError::InvalidCapability);
|
||||
}
|
||||
if !self.using.iter().any(|known| known == capability) {
|
||||
self.using.push(capability.to_owned());
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// The capabilities in `using`.
|
||||
pub fn capabilities(&self) -> impl Iterator<Item = &str> {
|
||||
self.using.iter().map(String::as_str)
|
||||
}
|
||||
|
||||
/// Lowers, for this request only, the largest response body accepted; the HTTP request built from it
|
||||
/// carries the limit ([`HttpRequest::max_response_bytes`](crate::HttpRequest::max_response_bytes)). It
|
||||
/// cannot exceed the limit for API responses.
|
||||
pub fn set_response_limit(&mut self, bytes: usize) {
|
||||
self.response_limit = Some(bytes);
|
||||
}
|
||||
|
||||
/// The response limit set with [`ApiRequest::set_response_limit`], if any.
|
||||
pub fn response_limit(&self) -> Option<usize> {
|
||||
self.response_limit
|
||||
}
|
||||
|
||||
/// Sets the request's `createdIds` (RFC 8620 §3.3); the response will then carry them back.
|
||||
pub fn set_created_ids(&mut self, ids: BTreeMap<Id, Id>) {
|
||||
self.created_ids = Some(ids);
|
||||
}
|
||||
|
||||
/// Adds a method call and returns its id.
|
||||
pub fn add_call(&mut self, method: &str, arguments: Map<String, Value>) -> Result<CallId, BuildError> {
|
||||
self.add_call_with_references(method, arguments, Vec::new())
|
||||
}
|
||||
|
||||
/// Adds a method call whose arguments include result references: each `(name, reference)` becomes the
|
||||
/// argument `#name` (RFC 8620 §3.7). An argument may not be given both ways.
|
||||
pub fn add_call_with_references(
|
||||
&mut self,
|
||||
method: &str,
|
||||
mut arguments: Map<String, Value>,
|
||||
references: Vec<(&str, ResultReference)>,
|
||||
) -> Result<CallId, BuildError> {
|
||||
if !is_printable_name(method, MAX_METHOD_NAME_LEN) {
|
||||
return Err(BuildError::InvalidMethodName);
|
||||
}
|
||||
if self.calls.len() >= MAX_CALLS {
|
||||
return Err(BuildError::TooManyCalls { limit: u64::try_from(MAX_CALLS).unwrap_or(u64::MAX) });
|
||||
}
|
||||
for (name, reference) in references {
|
||||
if name.is_empty() || name.starts_with('#') {
|
||||
return Err(BuildError::InvalidArgumentName);
|
||||
}
|
||||
if !self.calls.iter().any(|call| call.id == reference.result_of) {
|
||||
return Err(BuildError::UnknownCall);
|
||||
}
|
||||
let key = format!("#{name}");
|
||||
if arguments.contains_key(name) || arguments.contains_key(&key) {
|
||||
return Err(BuildError::DuplicateArgument);
|
||||
}
|
||||
arguments.insert(key, reference.to_json());
|
||||
}
|
||||
let duplicated = arguments.keys().any(|key| key.strip_prefix('#').is_some_and(|plain| !plain.is_empty() && arguments.contains_key(plain)));
|
||||
if duplicated {
|
||||
return Err(BuildError::DuplicateArgument);
|
||||
}
|
||||
let id = CallId { text: format!("c{}", self.calls.len()), request: self.tag };
|
||||
self.calls.push(Call { method: method.to_owned(), arguments, id: id.clone() });
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// A reference to the result of `call`, an earlier call of this request (RFC 8620 §3.7).
|
||||
pub fn reference(&self, call: &CallId, response_name: &str, path: &str) -> Result<ResultReference, BuildError> {
|
||||
if !self.calls.iter().any(|known| known.id == *call) {
|
||||
return Err(BuildError::UnknownCall);
|
||||
}
|
||||
if !is_printable_name(response_name, MAX_METHOD_NAME_LEN) {
|
||||
return Err(BuildError::InvalidMethodName);
|
||||
}
|
||||
if !is_json_pointer(path) {
|
||||
return Err(BuildError::InvalidPath);
|
||||
}
|
||||
Ok(ResultReference { result_of: call.clone(), name: response_name.to_owned(), path: path.to_owned() })
|
||||
}
|
||||
|
||||
/// The number of method calls.
|
||||
pub fn len(&self) -> usize {
|
||||
self.calls.len()
|
||||
}
|
||||
|
||||
/// Whether the request has no method call.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.calls.is_empty()
|
||||
}
|
||||
|
||||
/// The ids of the calls, in order.
|
||||
pub fn call_ids(&self) -> impl Iterator<Item = &CallId> {
|
||||
self.calls.iter().map(|call| &call.id)
|
||||
}
|
||||
|
||||
/// The request as a JSON text (RFC 8620 §3.3).
|
||||
pub fn to_json(&self) -> Result<Vec<u8>, BuildError> {
|
||||
if self.calls.is_empty() {
|
||||
return Err(BuildError::NoCalls);
|
||||
}
|
||||
let mut object = Map::new();
|
||||
object.insert("using".to_owned(), Value::Array(self.using.iter().cloned().map(Value::String).collect()));
|
||||
let calls = self
|
||||
.calls
|
||||
.iter()
|
||||
.map(|call| {
|
||||
Value::Array(vec![Value::String(call.method.clone()), Value::Object(call.arguments.clone()), Value::String(call.id.text.clone())])
|
||||
})
|
||||
.collect();
|
||||
object.insert("methodCalls".to_owned(), Value::Array(calls));
|
||||
if let Some(created) = &self.created_ids {
|
||||
let map = created.iter().map(|(key, value)| (key.as_str().to_owned(), Value::String(value.as_str().to_owned()))).collect();
|
||||
object.insert("createdIds".to_owned(), Value::Object(map));
|
||||
}
|
||||
serde_json::to_vec(&Value::Object(object)).map_err(|_| BuildError::Serialisation)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ApiRequest {
|
||||
/// Capabilities, methods and call ids; the arguments may hold personal data and are not shown.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let calls: Vec<String> = self.calls.iter().map(|call| format!("{} {}", call.id, call.method)).collect();
|
||||
f.debug_struct("ApiRequest").field("using", &self.using).field("calls", &calls).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,112 @@
|
||||
use super::*;
|
||||
use crate::session::CAPABILITY_MAIL;
|
||||
use serde_json::json;
|
||||
|
||||
fn args(value: Value) -> Map<String, Value> {
|
||||
match value {
|
||||
Value::Object(map) => map,
|
||||
_ => panic!("not an object"),
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8620 §3.3 and §3.3.1: using, methodCalls with name, arguments and call id.
|
||||
#[test]
|
||||
fn builds_request_object() {
|
||||
let mut request = ApiRequest::new();
|
||||
request.using(CAPABILITY_MAIL).unwrap().using(CAPABILITY_MAIL).unwrap();
|
||||
let first = request.add_call("method1", args(json!({"arg1": "arg1data", "arg2": "arg2data"}))).unwrap();
|
||||
let second = request.add_call("method2", args(json!({"arg1": "arg1data"}))).unwrap();
|
||||
assert_ne!(first, second);
|
||||
let body: Value = serde_json::from_slice(&request.to_json().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({
|
||||
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
"methodCalls": [
|
||||
["method1", {"arg1": "arg1data", "arg2": "arg2data"}, "c0"],
|
||||
["method2", {"arg1": "arg1data"}, "c1"]
|
||||
]
|
||||
})
|
||||
);
|
||||
assert_eq!(request.call_ids().map(CallId::as_str).collect::<Vec<_>>(), ["c0", "c1"]);
|
||||
}
|
||||
|
||||
// RFC 8620 §3.7: the Email/query then Email/get example with "#ids".
|
||||
#[test]
|
||||
fn result_references() {
|
||||
let mut request = ApiRequest::new();
|
||||
let query = request.add_call("Email/query", args(json!({"accountId": "A1", "limit": 10}))).unwrap();
|
||||
let ids = request.reference(&query, "Email/query", "/ids").unwrap();
|
||||
let get =
|
||||
request.add_call_with_references("Email/get", args(json!({"accountId": "A1", "properties": ["threadId"]})), vec![("ids", ids)]).unwrap();
|
||||
let threads = request.reference(&get, "Email/get", "/list/*/threadId").unwrap();
|
||||
request.add_call_with_references("Thread/get", args(json!({"accountId": "A1"})), vec![("ids", threads)]).unwrap();
|
||||
let body: Value = serde_json::from_slice(&request.to_json().unwrap()).unwrap();
|
||||
assert_eq!(body["methodCalls"][1][1]["#ids"], json!({"resultOf": "c0", "name": "Email/query", "path": "/ids"}));
|
||||
assert_eq!(body["methodCalls"][2][1]["#ids"], json!({"resultOf": "c1", "name": "Email/get", "path": "/list/*/threadId"}));
|
||||
}
|
||||
|
||||
// RFC 8620 §3.7: a reference must point to a previous call; "foo" and "#foo" together are invalid.
|
||||
#[test]
|
||||
fn reference_errors() {
|
||||
let mut request = ApiRequest::new();
|
||||
let first = request.add_call("Foo/changes", Map::new()).unwrap();
|
||||
let mut other = ApiRequest::new();
|
||||
assert_eq!(other.reference(&first, "Foo/changes", "/created"), Err(BuildError::UnknownCall));
|
||||
let mine = other.add_call("Foo/changes", Map::new()).unwrap();
|
||||
assert_eq!(mine.as_str(), first.as_str());
|
||||
assert_eq!(other.reference(&first, "Foo/changes", "/created"), Err(BuildError::UnknownCall));
|
||||
let reference = request.reference(&first, "Foo/changes", "/created").unwrap();
|
||||
assert_eq!(
|
||||
request.add_call_with_references("Foo/get", args(json!({"ids": []})), vec![("ids", reference.clone())]),
|
||||
Err(BuildError::DuplicateArgument)
|
||||
);
|
||||
assert_eq!(request.add_call("Foo/get", args(json!({"ids": [], "#ids": {}}))), Err(BuildError::DuplicateArgument));
|
||||
assert_eq!(other.add_call_with_references("Foo/get", Map::new(), vec![("ids", reference.clone())]), Err(BuildError::UnknownCall));
|
||||
assert_eq!(request.add_call_with_references("Foo/get", Map::new(), vec![("#ids", reference)]), Err(BuildError::InvalidArgumentName));
|
||||
}
|
||||
|
||||
// RFC 6901 §3 and RFC 8620 §3.7: the path is a JSON Pointer, with "*" allowed.
|
||||
#[test]
|
||||
fn pointer_syntax() {
|
||||
let mut request = ApiRequest::new();
|
||||
let call = request.add_call("Foo/get", Map::new()).unwrap();
|
||||
for good in ["", "/", "/list/*/id", "/a~0b/c~1d"] {
|
||||
assert!(request.reference(&call, "Foo/get", good).is_ok(), "{good:?}");
|
||||
}
|
||||
for bad in ["list", "/a~2", "/a~", &format!("/{}", "a".repeat(MAX_PATH_LEN))] {
|
||||
assert_eq!(request.reference(&call, "Foo/get", bad), Err(BuildError::InvalidPath), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Requirement 3.3: names are checked, and an empty request is not sent.
|
||||
#[test]
|
||||
fn names_and_empty() {
|
||||
let mut request = ApiRequest::new();
|
||||
assert_eq!(request.to_json(), Err(BuildError::NoCalls));
|
||||
assert_eq!(request.add_call("", Map::new()), Err(BuildError::InvalidMethodName));
|
||||
assert_eq!(request.add_call("Email get", Map::new()), Err(BuildError::InvalidMethodName));
|
||||
assert!(matches!(request.using("bad capability"), Err(BuildError::InvalidCapability)));
|
||||
for _ in 0..MAX_CALLS {
|
||||
request.add_call("Core/echo", Map::new()).unwrap();
|
||||
}
|
||||
assert!(matches!(request.add_call("Core/echo", Map::new()), Err(BuildError::TooManyCalls { .. })));
|
||||
}
|
||||
|
||||
// RFC 8620 §3.3: createdIds is sent when set.
|
||||
#[test]
|
||||
fn created_ids() {
|
||||
let mut request = ApiRequest::new();
|
||||
request.add_call("Core/echo", Map::new()).unwrap();
|
||||
request.set_created_ids(BTreeMap::from([(Id::new("k1").unwrap(), Id::new("M1").unwrap())]));
|
||||
let body: Value = serde_json::from_slice(&request.to_json().unwrap()).unwrap();
|
||||
assert_eq!(body["createdIds"], json!({"k1": "M1"}));
|
||||
}
|
||||
|
||||
// Requirement 2.3 spirit: the debug form does not show arguments.
|
||||
#[test]
|
||||
fn debug_hides_arguments() {
|
||||
let mut request = ApiRequest::new();
|
||||
request.add_call("Email/query", args(json!({"filter": {"text": "private words"}}))).unwrap();
|
||||
assert!(!format!("{request:?}").contains("private words"));
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Reading an API response (RFC 8620 §3.4) into per-call results, where a method-level error (§3.6.2) is the
|
||||
//! failure of that call only.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::classify::parse_json;
|
||||
use crate::error::{Error, Unusable};
|
||||
use crate::id::Id;
|
||||
use crate::redact::{Excerpt, Redactor};
|
||||
use crate::request::{CallId, MAX_METHOD_NAME_LEN};
|
||||
use crate::session::MAX_STATE_LEN;
|
||||
|
||||
/// The most method responses one API response may hold.
|
||||
pub const MAX_METHOD_RESPONSES: usize = 4096;
|
||||
/// The most entries of `createdIds` read.
|
||||
pub const MAX_CREATED_IDS: usize = 65_536;
|
||||
/// The longest call id read back.
|
||||
const MAX_CALL_ID_LEN: usize = 255;
|
||||
/// The longest error type kept.
|
||||
const MAX_ERROR_TYPE_LEN: usize = 64;
|
||||
|
||||
/// The type of a method-level error (RFC 8620 §3.6.2, and the method-specific ones of §5).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum MethodErrorKind {
|
||||
/// `serverUnavailable`: a resource was temporarily unavailable; trying later may work.
|
||||
ServerUnavailable,
|
||||
/// `serverFail`: an unexpected error; trying again is expected to fail.
|
||||
ServerFail,
|
||||
/// `serverPartialFail`: some changes happened; resynchronise.
|
||||
ServerPartialFail,
|
||||
/// `unknownMethod`
|
||||
UnknownMethod,
|
||||
/// `invalidArguments`
|
||||
InvalidArguments,
|
||||
/// `invalidResultReference`
|
||||
InvalidResultReference,
|
||||
/// `forbidden`
|
||||
Forbidden,
|
||||
/// `accountNotFound`
|
||||
AccountNotFound,
|
||||
/// `accountNotSupportedByMethod`
|
||||
AccountNotSupportedByMethod,
|
||||
/// `accountReadOnly`
|
||||
AccountReadOnly,
|
||||
/// `requestTooLarge` (§5.1, §5.3)
|
||||
RequestTooLarge,
|
||||
/// `cannotCalculateChanges` (§5.2, §5.6)
|
||||
CannotCalculateChanges,
|
||||
/// `stateMismatch` (§5.3, §5.4)
|
||||
StateMismatch,
|
||||
/// `fromAccountNotFound` (§5.4)
|
||||
FromAccountNotFound,
|
||||
/// `fromAccountNotSupportedByMethod` (§5.4)
|
||||
FromAccountNotSupportedByMethod,
|
||||
/// `anchorNotFound` (§5.5)
|
||||
AnchorNotFound,
|
||||
/// `unsupportedSort` (§5.5)
|
||||
UnsupportedSort,
|
||||
/// `unsupportedFilter` (§5.5)
|
||||
UnsupportedFilter,
|
||||
/// `tooManyChanges` (§5.6)
|
||||
TooManyChanges,
|
||||
/// Any other type, checked to be a short identifier (empty when the server gave none or a bad one). RFC 8620
|
||||
/// §3.6.2: a client must treat a type it does not understand like `serverFail`.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl MethodErrorKind {
|
||||
fn from_type(kind: Option<&str>) -> Self {
|
||||
match kind.unwrap_or("") {
|
||||
"serverUnavailable" => Self::ServerUnavailable,
|
||||
"serverFail" => Self::ServerFail,
|
||||
"serverPartialFail" => Self::ServerPartialFail,
|
||||
"unknownMethod" => Self::UnknownMethod,
|
||||
"invalidArguments" => Self::InvalidArguments,
|
||||
"invalidResultReference" => Self::InvalidResultReference,
|
||||
"forbidden" => Self::Forbidden,
|
||||
"accountNotFound" => Self::AccountNotFound,
|
||||
"accountNotSupportedByMethod" => Self::AccountNotSupportedByMethod,
|
||||
"accountReadOnly" => Self::AccountReadOnly,
|
||||
"requestTooLarge" => Self::RequestTooLarge,
|
||||
"cannotCalculateChanges" => Self::CannotCalculateChanges,
|
||||
"stateMismatch" => Self::StateMismatch,
|
||||
"fromAccountNotFound" => Self::FromAccountNotFound,
|
||||
"fromAccountNotSupportedByMethod" => Self::FromAccountNotSupportedByMethod,
|
||||
"anchorNotFound" => Self::AnchorNotFound,
|
||||
"unsupportedSort" => Self::UnsupportedSort,
|
||||
"unsupportedFilter" => Self::UnsupportedFilter,
|
||||
"tooManyChanges" => Self::TooManyChanges,
|
||||
other if other.len() <= MAX_ERROR_TYPE_LEN && other.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') => {
|
||||
Self::Other(other.to_owned())
|
||||
}
|
||||
_ => Self::Other(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The type's name as the server sent it (empty for an unrecognisable one).
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::ServerUnavailable => "serverUnavailable",
|
||||
Self::ServerFail => "serverFail",
|
||||
Self::ServerPartialFail => "serverPartialFail",
|
||||
Self::UnknownMethod => "unknownMethod",
|
||||
Self::InvalidArguments => "invalidArguments",
|
||||
Self::InvalidResultReference => "invalidResultReference",
|
||||
Self::Forbidden => "forbidden",
|
||||
Self::AccountNotFound => "accountNotFound",
|
||||
Self::AccountNotSupportedByMethod => "accountNotSupportedByMethod",
|
||||
Self::AccountReadOnly => "accountReadOnly",
|
||||
Self::RequestTooLarge => "requestTooLarge",
|
||||
Self::CannotCalculateChanges => "cannotCalculateChanges",
|
||||
Self::StateMismatch => "stateMismatch",
|
||||
Self::FromAccountNotFound => "fromAccountNotFound",
|
||||
Self::FromAccountNotSupportedByMethod => "fromAccountNotSupportedByMethod",
|
||||
Self::AnchorNotFound => "anchorNotFound",
|
||||
Self::UnsupportedSort => "unsupportedSort",
|
||||
Self::UnsupportedFilter => "unsupportedFilter",
|
||||
Self::TooManyChanges => "tooManyChanges",
|
||||
Self::Other(name) => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the same call may succeed later unchanged (only `serverUnavailable`, RFC 8620 §3.6.2).
|
||||
pub fn may_succeed_later(&self) -> bool {
|
||||
matches!(self, Self::ServerUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
/// A method-level error (RFC 8620 §3.6.2).
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct MethodError {
|
||||
kind: MethodErrorKind,
|
||||
description: Option<Excerpt>,
|
||||
arguments: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl MethodError {
|
||||
/// The error type.
|
||||
pub fn kind(&self) -> &MethodErrorKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
/// The server's `description`, cleaned (RFC 8620 §3.6.2: not meant for end users as is).
|
||||
pub fn description(&self) -> Option<&Excerpt> {
|
||||
self.description.as_ref()
|
||||
}
|
||||
|
||||
/// All the error's arguments, for types that carry more (raw server data: do not show or log them).
|
||||
pub fn arguments(&self) -> &Map<String, Value> {
|
||||
&self.arguments
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MethodError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("MethodError").field("kind", &self.kind).field("description", &self.description).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MethodError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.kind.as_str().is_empty() {
|
||||
f.write_str("the server refused this call without saying why")?;
|
||||
} else {
|
||||
write!(f, "the server refused this call with the error {}", self.kind.as_str())?;
|
||||
}
|
||||
if let Some(description) = &self.description {
|
||||
write!(f, ": \"{description}\"")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MethodError {}
|
||||
|
||||
/// One response of a call (RFC 8620 §3.2).
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct MethodResponse {
|
||||
name: String,
|
||||
arguments: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl MethodResponse {
|
||||
/// The response name (the method's name, for a successful call).
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// The response's arguments.
|
||||
pub fn arguments(&self) -> &Map<String, Value> {
|
||||
&self.arguments
|
||||
}
|
||||
|
||||
/// The response's arguments, taken.
|
||||
pub fn into_arguments(self) -> Map<String, Value> {
|
||||
self.arguments
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MethodResponse {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("MethodResponse").field("name", &self.name).field("arguments", &self.arguments.len()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a call has no usable result.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum CallFailure {
|
||||
/// The server answered the call with an error.
|
||||
Method(MethodError),
|
||||
/// The response holds nothing for this call id (or it was already taken).
|
||||
Missing,
|
||||
/// The call's responses do not include the name asked for.
|
||||
UnexpectedResponse,
|
||||
}
|
||||
|
||||
impl fmt::Display for CallFailure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Method(error) => fmt::Display::fmt(error, f),
|
||||
Self::Missing => f.write_str("the server's answer holds no result for this call; the server may have stopped before reaching it"),
|
||||
Self::UnexpectedResponse => f.write_str("the server answered this call with a response of another kind than the one asked for"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CallFailure {}
|
||||
|
||||
type CallResult = Result<Vec<MethodResponse>, MethodError>;
|
||||
|
||||
/// A parsed API response.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ApiResponse {
|
||||
session_state: String,
|
||||
created_ids: Option<BTreeMap<Id, Id>>,
|
||||
calls: BTreeMap<String, CallResult>,
|
||||
}
|
||||
|
||||
fn shape(what: &'static str) -> Error {
|
||||
Error::Unusable(Unusable::Shape(what))
|
||||
}
|
||||
|
||||
fn is_name(text: &str, max: usize) -> bool {
|
||||
!text.is_empty() && text.len() <= max && text.bytes().all(|b| (0x21..=0x7E).contains(&b))
|
||||
}
|
||||
|
||||
impl ApiResponse {
|
||||
/// Reads a Response object (RFC 8620 §3.4). `redactor` cleans the descriptions of method errors.
|
||||
pub fn parse(body: &[u8], redactor: &Redactor) -> Result<Self, Error> {
|
||||
let Value::Object(mut object) = parse_json(body)? else {
|
||||
return Err(shape("the response is not a JSON object"));
|
||||
};
|
||||
let session_state = match object.remove("sessionState") {
|
||||
Some(Value::String(state)) if state.len() <= MAX_STATE_LEN => state,
|
||||
_ => return Err(shape("the response has no valid sessionState")),
|
||||
};
|
||||
let Some(Value::Array(responses)) = object.remove("methodResponses") else {
|
||||
return Err(shape("the response has no methodResponses array"));
|
||||
};
|
||||
if responses.len() > MAX_METHOD_RESPONSES {
|
||||
return Err(shape("the response holds too many method responses"));
|
||||
}
|
||||
let created_ids = match object.remove("createdIds") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Object(map)) if map.len() <= MAX_CREATED_IDS => {
|
||||
Some(map.iter().filter_map(|(key, value)| Some((Id::new(key).ok()?, Id::new(value.as_str()?).ok()?))).collect())
|
||||
}
|
||||
Some(_) => return Err(shape("createdIds is not an object of ids")),
|
||||
};
|
||||
let mut calls: BTreeMap<String, CallResult> = BTreeMap::new();
|
||||
for invocation in responses {
|
||||
let Value::Array(parts) = invocation else {
|
||||
return Err(shape("a method response is not an array"));
|
||||
};
|
||||
let mut parts = parts.into_iter();
|
||||
let (Some(Value::String(name)), Some(Value::Object(arguments)), Some(Value::String(call_id)), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return Err(shape("a method response is not a [name, arguments, call id] triple"));
|
||||
};
|
||||
if !is_name(&name, MAX_METHOD_NAME_LEN) {
|
||||
return Err(shape("a method response has an invalid name"));
|
||||
}
|
||||
if call_id.len() > MAX_CALL_ID_LEN {
|
||||
return Err(shape("a method response has an overlong call id"));
|
||||
}
|
||||
let entry = calls.entry(call_id).or_insert_with(|| Ok(Vec::new()));
|
||||
let Ok(list) = entry else {
|
||||
// RFC 8620 §3.6.2: after an error, nothing more happens within that call.
|
||||
continue;
|
||||
};
|
||||
if name == "error" {
|
||||
let mut kind = MethodErrorKind::from_type(arguments.get("type").and_then(Value::as_str));
|
||||
if let MethodErrorKind::Other(name) = &kind
|
||||
&& redactor.mentions_secret(name)
|
||||
{
|
||||
kind = MethodErrorKind::Other(String::new());
|
||||
}
|
||||
let description = arguments.get("description").and_then(Value::as_str).map(|text| redactor.excerpt(text));
|
||||
*entry = Err(MethodError { kind, description, arguments });
|
||||
} else {
|
||||
list.push(MethodResponse { name, arguments });
|
||||
}
|
||||
}
|
||||
Ok(Self { session_state, created_ids, calls })
|
||||
}
|
||||
|
||||
/// The session state the server reported (RFC 8620 §3.4 `sessionState`).
|
||||
pub fn session_state(&self) -> &str {
|
||||
&self.session_state
|
||||
}
|
||||
|
||||
/// The `createdIds` the server returned, valid entries only.
|
||||
pub fn created_ids(&self) -> Option<&BTreeMap<Id, Id>> {
|
||||
self.created_ids.as_ref()
|
||||
}
|
||||
|
||||
/// Takes every response of a call; a method-level error makes the call fail.
|
||||
pub fn take(&mut self, call: &CallId) -> Result<Vec<MethodResponse>, CallFailure> {
|
||||
match self.calls.remove(call.as_str()) {
|
||||
Some(Ok(responses)) if !responses.is_empty() => Ok(responses),
|
||||
Some(Ok(_)) | None => Err(CallFailure::Missing),
|
||||
Some(Err(error)) => Err(CallFailure::Method(error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the arguments of the call's response named `name` (for a plain call, the method's own name).
|
||||
pub fn take_named(&mut self, call: &CallId, name: &str) -> Result<Map<String, Value>, CallFailure> {
|
||||
self.take(call)?.into_iter().find(|response| response.name == name).map(MethodResponse::into_arguments).ok_or(CallFailure::UnexpectedResponse)
|
||||
}
|
||||
|
||||
/// The call ids present in the response, untaken.
|
||||
pub fn call_ids(&self) -> impl Iterator<Item = &str> {
|
||||
self.calls.keys().map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ApiResponse {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ApiResponse").field("session_state", &self.session_state).field("calls", &self.calls.len()).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::*;
|
||||
use crate::request::ApiRequest;
|
||||
use serde_json::json;
|
||||
|
||||
fn calls(count: usize) -> (ApiRequest, Vec<CallId>) {
|
||||
let mut request = ApiRequest::new();
|
||||
let ids = (0..count).map(|_| request.add_call("Core/echo", Map::new()).unwrap()).collect();
|
||||
(request, ids)
|
||||
}
|
||||
|
||||
// RFC 8620 §3.4.1: the example response, with an error for the third call.
|
||||
#[test]
|
||||
fn reads_example_response() {
|
||||
let (_, ids) = calls(3);
|
||||
let body = br#"{
|
||||
"methodResponses": [
|
||||
["method1", {"arg1": 3, "arg2": "foo"}, "c0"],
|
||||
["method2", {"isBlah": true}, "c1"],
|
||||
["anotherResponseFromMethod2", {"data": 10, "yetmoredata": "Hello"}, "c1"],
|
||||
["error", {"type": "unknownMethod"}, "c2"]
|
||||
],
|
||||
"sessionState": "75128aab4b1b"
|
||||
}"#;
|
||||
let mut response = ApiResponse::parse(body, &Redactor::new()).unwrap();
|
||||
assert_eq!(response.session_state(), "75128aab4b1b");
|
||||
assert_eq!(response.take_named(&ids[0], "method1").unwrap()["arg2"], json!("foo"));
|
||||
let second = response.take(&ids[1]).unwrap();
|
||||
assert_eq!(second.iter().map(MethodResponse::name).collect::<Vec<_>>(), ["method2", "anotherResponseFromMethod2"]);
|
||||
match response.take(&ids[2]) {
|
||||
Err(CallFailure::Method(error)) => {
|
||||
assert_eq!(error.kind(), &MethodErrorKind::UnknownMethod)
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(response.take(&ids[0]), Err(CallFailure::Missing));
|
||||
}
|
||||
|
||||
// RFC 8620 §3.6.2: an error is one call's failure; unknown types are kept (and treated like serverFail).
|
||||
#[test]
|
||||
fn method_errors() {
|
||||
let (_, ids) = calls(2);
|
||||
let body = json!({
|
||||
"methodResponses": [
|
||||
["error", {"type": "somethingNew", "description": "Line one\nwith secret-pass inside"}, "c0"],
|
||||
["Core/echo", {}, "c1"]
|
||||
],
|
||||
"sessionState": "s1"
|
||||
});
|
||||
let mut redactor = Redactor::new();
|
||||
redactor.add("secret-pass");
|
||||
let mut response = ApiResponse::parse(body.to_string().as_bytes(), &redactor).unwrap();
|
||||
match response.take(&ids[0]) {
|
||||
Err(CallFailure::Method(error)) => {
|
||||
assert_eq!(error.kind(), &MethodErrorKind::Other("somethingNew".into()));
|
||||
assert_eq!(error.description().unwrap().as_str(), "Line one with [redacted] inside");
|
||||
assert!(!error.to_string().contains('\n'));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(response.take(&ids[1]).is_ok());
|
||||
assert!(MethodErrorKind::ServerUnavailable.may_succeed_later());
|
||||
assert_eq!(MethodErrorKind::from_type(None), MethodErrorKind::Other(String::new()));
|
||||
assert_eq!(MethodErrorKind::from_type(Some("x y")), MethodErrorKind::Other(String::new()));
|
||||
assert_eq!(MethodErrorKind::from_type(Some("stateMismatch")), MethodErrorKind::StateMismatch);
|
||||
}
|
||||
|
||||
// Requirement 3.5: an error type that echoes a secret is not kept.
|
||||
#[test]
|
||||
fn error_type_echoing_a_secret() {
|
||||
let (_, ids) = calls(1);
|
||||
let mut redactor = Redactor::new();
|
||||
redactor.add("hunter22");
|
||||
let body = json!({"methodResponses": [["error", {"type": "hunter22"}, "c0"]], "sessionState": "s"});
|
||||
let mut response = ApiResponse::parse(body.to_string().as_bytes(), &redactor).unwrap();
|
||||
match response.take(&ids[0]) {
|
||||
Err(CallFailure::Method(error)) => {
|
||||
assert_eq!(error.kind(), &MethodErrorKind::Other(String::new()))
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8620 §3.6.2: nothing more happens within a call after its error.
|
||||
#[test]
|
||||
fn responses_after_error_are_ignored() {
|
||||
let (_, ids) = calls(1);
|
||||
let body = json!({
|
||||
"methodResponses": [["Foo/set", {}, "c0"], ["error", {"type": "serverPartialFail"}, "c0"], ["Foo/get", {}, "c0"]],
|
||||
"sessionState": "s"
|
||||
});
|
||||
let mut response = ApiResponse::parse(body.to_string().as_bytes(), &Redactor::new()).unwrap();
|
||||
assert!(matches!(response.take(&ids[0]), Err(CallFailure::Method(error)) if error.kind() == &MethodErrorKind::ServerPartialFail));
|
||||
}
|
||||
|
||||
// Requirement 3.3: a name other than the one asked for is not silently accepted.
|
||||
#[test]
|
||||
fn unexpected_names() {
|
||||
let (_, ids) = calls(1);
|
||||
let body = json!({"methodResponses": [["Other/get", {}, "c0"]], "sessionState": "s"});
|
||||
let mut response = ApiResponse::parse(body.to_string().as_bytes(), &Redactor::new()).unwrap();
|
||||
assert_eq!(response.take_named(&ids[0], "Foo/get"), Err(CallFailure::UnexpectedResponse));
|
||||
}
|
||||
|
||||
// RFC 8620 §3.3–§3.4: createdIds are returned when given; invalid ids are dropped.
|
||||
#[test]
|
||||
fn created_ids() {
|
||||
let body = json!({"methodResponses": [], "sessionState": "s", "createdIds": {"k1": "M1", "bad key": "M2", "k3": "bad value"}});
|
||||
let response = ApiResponse::parse(body.to_string().as_bytes(), &Redactor::new()).unwrap();
|
||||
let created = response.created_ids().unwrap();
|
||||
assert_eq!(created.len(), 1);
|
||||
assert_eq!(created.get(&Id::new("k1").unwrap()).unwrap().as_str(), "M1");
|
||||
}
|
||||
|
||||
// RFC 8620 §3.2 and §3.4: anything that is not a proper Response object is unusable.
|
||||
#[test]
|
||||
fn refuses_bad_shapes() {
|
||||
for body in [
|
||||
r#"[]"#,
|
||||
r#"{"sessionState":"s"}"#,
|
||||
r#"{"methodResponses":[]}"#,
|
||||
r#"{"methodResponses":{},"sessionState":"s"}"#,
|
||||
r#"{"methodResponses":[["a",{},"c0","extra"]],"sessionState":"s"}"#,
|
||||
r#"{"methodResponses":[["a",[],"c0"]],"sessionState":"s"}"#,
|
||||
r#"{"methodResponses":[[1,{},"c0"]],"sessionState":"s"}"#,
|
||||
r#"{"methodResponses":[["a",{}]],"sessionState":"s"}"#,
|
||||
r#"{"methodResponses":["x"],"sessionState":"s"}"#,
|
||||
r#"{"methodResponses":[["",{},"c0"]],"sessionState":"s"}"#,
|
||||
r#"{"methodResponses":[["a b",{},"c0"]],"sessionState":"s"}"#,
|
||||
r#"{"methodResponses":[],"sessionState":7}"#,
|
||||
r#"{"methodResponses":[],"sessionState":"s","createdIds":[]}"#,
|
||||
] {
|
||||
assert!(matches!(ApiResponse::parse(body.as_bytes(), &Redactor::new()), Err(Error::Unusable(Unusable::Shape(_)))), "{body}");
|
||||
}
|
||||
assert_eq!(ApiResponse::parse(b"{", &Redactor::new()).unwrap_err(), Error::Unusable(Unusable::InvalidJson));
|
||||
}
|
||||
|
||||
// RFC 8620 §8.4 and §8.5 (resource exhaustion): the number of responses is bounded.
|
||||
#[test]
|
||||
fn bounds() {
|
||||
let responses: Vec<Value> = (0..=MAX_METHOD_RESPONSES).map(|i| json!(["Core/echo", {}, format!("c{i}")])).collect();
|
||||
let body = json!({"methodResponses": responses, "sessionState": "s"});
|
||||
assert!(matches!(ApiResponse::parse(body.to_string().as_bytes(), &Redactor::new()), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
let body = json!({"methodResponses": [["Core/echo", {}, "c".repeat(256)]], "sessionState": "s"});
|
||||
assert!(ApiResponse::parse(body.to_string().as_bytes(), &Redactor::new()).is_err());
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! `Retry-After` (RFC 9110 §10.2.3) and the HTTP-date forms it may use (RFC 9110 §5.6.7).
|
||||
//!
|
||||
//! The crate reads no clock: a date is kept as it was given, and turned into a delay against a "now" the
|
||||
//! caller passes to [`RetryAfter::delay_from`]. Every delay, whichever form it came in, is clamped to
|
||||
//! [`MAX_RETRY_DELAY`], so that a hostile server cannot silence the client for days.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// The longest delay a server can impose through `Retry-After`. Defined here and nowhere else.
|
||||
pub const MAX_RETRY_DELAY: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
const SECONDS_PER_DAY: u64 = 86_400;
|
||||
const LAST_YEAR: u64 = 9999;
|
||||
|
||||
/// A point in time, in whole seconds since 1970-01-01T00:00:00Z, supplied by the caller's clock.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct UnixTime(u64);
|
||||
|
||||
impl UnixTime {
|
||||
/// The time `secs` seconds after the Unix epoch.
|
||||
pub const fn from_secs(secs: u64) -> Self {
|
||||
Self(secs)
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch.
|
||||
pub const fn as_secs(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP-date in any of its three forms (RFC 9110 §5.6.7), not yet tied to a calendar century when it came
|
||||
/// in the two-digit RFC 850 form.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct HttpDate {
|
||||
year: u16,
|
||||
two_digit_year: bool,
|
||||
month: u8,
|
||||
day: u8,
|
||||
hour: u8,
|
||||
minute: u8,
|
||||
second: u8,
|
||||
}
|
||||
|
||||
/// What a `Retry-After` field asked for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RetryAfter {
|
||||
/// A number of seconds, already clamped to [`MAX_RETRY_DELAY`].
|
||||
Delay(Duration),
|
||||
/// A date; see [`RetryAfter::delay_from`].
|
||||
At(HttpDate),
|
||||
}
|
||||
|
||||
const DAY_NAMES: [&[u8]; 7] = [b"Mon", b"Tue", b"Wed", b"Thu", b"Fri", b"Sat", b"Sun"];
|
||||
const LONG_DAY_NAMES: [&[u8]; 7] = [b"Monday", b"Tuesday", b"Wednesday", b"Thursday", b"Friday", b"Saturday", b"Sunday"];
|
||||
const MONTHS: [&[u8]; 12] = [b"Jan", b"Feb", b"Mar", b"Apr", b"May", b"Jun", b"Jul", b"Aug", b"Sep", b"Oct", b"Nov", b"Dec"];
|
||||
|
||||
struct Cursor<'a> {
|
||||
rest: &'a [u8],
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn new(text: &'a str) -> Self {
|
||||
Self { rest: text.as_bytes() }
|
||||
}
|
||||
|
||||
fn literal(&mut self, expected: &[u8]) -> Option<()> {
|
||||
self.rest = self.rest.strip_prefix(expected)?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn one_of(&mut self, options: &[&[u8]]) -> Option<usize> {
|
||||
let (index, rest) = options.iter().enumerate().find_map(|(index, option)| Some((index, self.rest.strip_prefix(*option)?)))?;
|
||||
self.rest = rest;
|
||||
Some(index)
|
||||
}
|
||||
|
||||
fn digits(&mut self, count: usize) -> Option<u16> {
|
||||
let (head, rest) = self.rest.split_at_checked(count)?;
|
||||
let mut value: u16 = 0;
|
||||
for &b in head {
|
||||
if !b.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
value = value.checked_mul(10)?.checked_add(u16::from(b.saturating_sub(b'0')))?;
|
||||
}
|
||||
self.rest = rest;
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn time(&mut self) -> Option<(u8, u8, u8)> {
|
||||
let hour = self.digits(2)?;
|
||||
self.literal(b":")?;
|
||||
let minute = self.digits(2)?;
|
||||
self.literal(b":")?;
|
||||
let second = self.digits(2)?;
|
||||
if hour > 23 || minute > 59 || second > 60 {
|
||||
return None;
|
||||
}
|
||||
Some((u8::try_from(hour).ok()?, u8::try_from(minute).ok()?, u8::try_from(second).ok()?))
|
||||
}
|
||||
|
||||
fn month(&mut self) -> Option<u8> {
|
||||
let index = self.one_of(&MONTHS)?;
|
||||
u8::try_from(index).ok()?.checked_add(1)
|
||||
}
|
||||
|
||||
fn finished(&self) -> bool {
|
||||
self.rest.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `year` has a 29 February (Gregorian calendar, as RFC 5322 §3.3 dates use): a century year only
|
||||
/// when divisible by 400, any other year when divisible by 4.
|
||||
fn has_leap_day(year: u64) -> bool {
|
||||
if year.is_multiple_of(100) { year.is_multiple_of(400) } else { year.is_multiple_of(4) }
|
||||
}
|
||||
|
||||
/// The number of days of `month` (1 to 12) in `year`, 0 for anything else.
|
||||
///
|
||||
/// Apart from February, months alternate 31 and 30 days from January, and the alternation starts again at
|
||||
/// August (July and August both have 31). Adding `month / 8`, which is 1 from August on, shifts the parity
|
||||
/// there: `30 + (month + month / 8) % 2`.
|
||||
fn month_length(year: u64, month: u8) -> u8 {
|
||||
match month {
|
||||
2 => 28u8.saturating_add(u8::from(has_leap_day(year))),
|
||||
1..=12 => 30u8.saturating_add(month.saturating_add(month / 8) % 2),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Leap years in `1..=year`.
|
||||
fn leaps_through(year: u64) -> u64 {
|
||||
(year / 4).saturating_sub(year / 100).saturating_add(year / 400)
|
||||
}
|
||||
|
||||
/// Days from 1970-01-01 to January 1st of `year` (`year` ≥ 1970).
|
||||
fn days_before_year(year: u64) -> Option<u64> {
|
||||
let years = year.checked_sub(1970)?;
|
||||
let leaps = leaps_through(year.checked_sub(1)?).checked_sub(leaps_through(1969))?;
|
||||
years.checked_mul(365)?.checked_add(leaps)
|
||||
}
|
||||
|
||||
/// The calendar year holding the given Unix time, capped at year 9999.
|
||||
fn year_of(now: UnixTime) -> u64 {
|
||||
let days = now.as_secs() / SECONDS_PER_DAY;
|
||||
let mut year = 1970u64.saturating_add(days / 366).min(LAST_YEAR);
|
||||
while year < LAST_YEAR {
|
||||
match days_before_year(year.saturating_add(1)) {
|
||||
Some(start) if start <= days => year = year.saturating_add(1),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
year
|
||||
}
|
||||
|
||||
fn valid_day(year: u64, month: u8, day: u8, two_digit_year: bool) -> bool {
|
||||
// For a two-digit year the century, and so the leap year, is not known yet: allow 29 February until then.
|
||||
let last = if two_digit_year && month == 2 { 29 } else { month_length(year, month) };
|
||||
day >= 1 && day <= last
|
||||
}
|
||||
|
||||
impl HttpDate {
|
||||
/// Parses an IMF-fixdate, an RFC 850 date or an asctime date (RFC 9110 §5.6.7). The forms are case
|
||||
/// sensitive, as the grammar says; the day name is checked for spelling but not against the date.
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
Self::imf_fixdate(text).or_else(|| Self::rfc850(text)).or_else(|| Self::asctime(text))
|
||||
}
|
||||
|
||||
fn build(year: u16, two_digit_year: bool, month: u8, day: u16, (hour, minute, second): (u8, u8, u8)) -> Option<Self> {
|
||||
let day = u8::try_from(day).ok()?;
|
||||
if !valid_day(u64::from(year), month, day, two_digit_year) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { year, two_digit_year, month, day, hour, minute, second })
|
||||
}
|
||||
|
||||
/// `Sat, 29 Oct 1994 19:43:31 GMT`
|
||||
fn imf_fixdate(text: &str) -> Option<Self> {
|
||||
let mut c = Cursor::new(text);
|
||||
c.one_of(&DAY_NAMES)?;
|
||||
c.literal(b", ")?;
|
||||
let day = c.digits(2)?;
|
||||
c.literal(b" ")?;
|
||||
let month = c.month()?;
|
||||
c.literal(b" ")?;
|
||||
let year = c.digits(4)?;
|
||||
c.literal(b" ")?;
|
||||
let time = c.time()?;
|
||||
c.literal(b" GMT")?;
|
||||
if !c.finished() {
|
||||
return None;
|
||||
}
|
||||
Self::build(year, false, month, day, time)
|
||||
}
|
||||
|
||||
/// `Saturday, 29-Oct-94 19:43:31 GMT`
|
||||
fn rfc850(text: &str) -> Option<Self> {
|
||||
let mut c = Cursor::new(text);
|
||||
c.one_of(&LONG_DAY_NAMES)?;
|
||||
c.literal(b", ")?;
|
||||
let day = c.digits(2)?;
|
||||
c.literal(b"-")?;
|
||||
let month = c.month()?;
|
||||
c.literal(b"-")?;
|
||||
let year = c.digits(2)?;
|
||||
c.literal(b" ")?;
|
||||
let time = c.time()?;
|
||||
c.literal(b" GMT")?;
|
||||
if !c.finished() {
|
||||
return None;
|
||||
}
|
||||
Self::build(year, true, month, day, time)
|
||||
}
|
||||
|
||||
/// `Wed Jul 1 00:00:00 2009`
|
||||
fn asctime(text: &str) -> Option<Self> {
|
||||
let mut c = Cursor::new(text);
|
||||
c.one_of(&DAY_NAMES)?;
|
||||
c.literal(b" ")?;
|
||||
let month = c.month()?;
|
||||
c.literal(b" ")?;
|
||||
let day = if c.literal(b" ").is_some() { c.digits(1)? } else { c.digits(2)? };
|
||||
c.literal(b" ")?;
|
||||
let time = c.time()?;
|
||||
c.literal(b" ")?;
|
||||
let year = c.digits(4)?;
|
||||
if !c.finished() {
|
||||
return None;
|
||||
}
|
||||
Self::build(year, false, month, day, time)
|
||||
}
|
||||
|
||||
/// The full year, resolving a two-digit RFC 850 year against `now` (RFC 9110 §5.6.7: a year that appears
|
||||
/// to be more than 50 years in the future is the most recent past year with the same last two digits).
|
||||
pub fn year(&self, now: UnixTime) -> u64 {
|
||||
if !self.two_digit_year {
|
||||
return u64::from(self.year);
|
||||
}
|
||||
let current = year_of(now);
|
||||
let candidate = (current / 100).saturating_mul(100).saturating_add(u64::from(self.year));
|
||||
if candidate > current.saturating_add(50) { candidate.saturating_sub(100) } else { candidate }
|
||||
}
|
||||
|
||||
/// The date as a Unix time, `now` serving only to place a two-digit year. A date before 1970 is the
|
||||
/// epoch; a 29 February that turns out not to exist gives `None`.
|
||||
pub fn to_unix(&self, now: UnixTime) -> Option<UnixTime> {
|
||||
let year = self.year(now);
|
||||
if !valid_day(year, self.month, self.day, false) {
|
||||
return None;
|
||||
}
|
||||
if year < 1970 {
|
||||
return Some(UnixTime(0));
|
||||
}
|
||||
let mut days = days_before_year(year)?;
|
||||
for month in 1..self.month {
|
||||
days = days.checked_add(u64::from(month_length(year, month)))?;
|
||||
}
|
||||
days = days.checked_add(u64::from(self.day).checked_sub(1)?)?;
|
||||
let seconds = days
|
||||
.checked_mul(SECONDS_PER_DAY)?
|
||||
.checked_add(u64::from(self.hour).checked_mul(3600)?)?
|
||||
.checked_add(u64::from(self.minute).checked_mul(60)?)?
|
||||
.checked_add(u64::from(self.second))?;
|
||||
Some(UnixTime(seconds))
|
||||
}
|
||||
}
|
||||
|
||||
impl RetryAfter {
|
||||
/// Parses a `Retry-After` field value: `delay-seconds` or an HTTP-date (RFC 9110 §10.2.3). Surrounding
|
||||
/// spaces and tabs are ignored; anything else unexpected gives `None`, as if the field were absent.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
let value = value.trim_matches([' ', '\t']);
|
||||
if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) {
|
||||
let mut seconds: u64 = 0;
|
||||
for b in value.bytes() {
|
||||
seconds = seconds.saturating_mul(10).saturating_add(u64::from(b.saturating_sub(b'0')));
|
||||
}
|
||||
return Some(Self::Delay(Duration::from_secs(seconds).min(MAX_RETRY_DELAY)));
|
||||
}
|
||||
HttpDate::parse(value).map(Self::At)
|
||||
}
|
||||
|
||||
/// How long to wait from `now`: never negative, never more than [`MAX_RETRY_DELAY`].
|
||||
pub fn delay_from(&self, now: UnixTime) -> Duration {
|
||||
match self {
|
||||
Self::Delay(delay) => (*delay).min(MAX_RETRY_DELAY),
|
||||
Self::At(date) => match date.to_unix(now) {
|
||||
Some(at) => Duration::from_secs(at.as_secs().saturating_sub(now.as_secs())).min(MAX_RETRY_DELAY),
|
||||
None => Duration::ZERO,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::*;
|
||||
|
||||
// 1994-10-29T19:43:31Z, the If-Modified-Since example of RFC 9110 §13.1.3.
|
||||
const MODIFIED_SINCE: u64 = 783_459_811;
|
||||
// 2009-07-27T12:28:53Z, the Date of the example exchange of RFC 9110 §3.9.
|
||||
const EXCHANGE_DATE: u64 = 1_248_697_733;
|
||||
// 2012-01-20T15:41:54Z, the Date of the 416 example of RFC 9110 §15.5.17.
|
||||
const RANGE_EXAMPLE: u64 = 1_327_074_114;
|
||||
|
||||
// RFC 9110 §5.6.7: the three forms of one instant (the §13.1.3 date), all read alike.
|
||||
#[test]
|
||||
fn parses_three_forms() {
|
||||
let now = UnixTime::from_secs(RANGE_EXAMPLE);
|
||||
for text in ["Sat, 29 Oct 1994 19:43:31 GMT", "Saturday, 29-Oct-94 19:43:31 GMT", "Sat Oct 29 19:43:31 1994"] {
|
||||
let date = HttpDate::parse(text).unwrap_or_else(|| panic!("{text}"));
|
||||
assert_eq!(date.to_unix(now), Some(UnixTime::from_secs(MODIFIED_SINCE)), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 9110 §3.9 and §8.8.3.3 example dates, an asctime day padded with a space, and a 29 February in the
|
||||
// century leap year 2400 (divisible by 400).
|
||||
#[test]
|
||||
fn converts_dates() {
|
||||
let now = UnixTime::from_secs(RANGE_EXAMPLE);
|
||||
for (text, expected) in [
|
||||
("Mon, 27 Jul 2009 12:28:53 GMT", EXCHANGE_DATE),
|
||||
("Wed, 22 Jul 2009 19:15:56 GMT", 1_248_290_156),
|
||||
("Fri, 26 Mar 2010 00:05:00 GMT", 1_269_561_900),
|
||||
("Wed Jul 1 00:00:00 2009", 1_246_406_400),
|
||||
("Tue, 29 Feb 2400 06:41:17 GMT", 13_574_587_277),
|
||||
] {
|
||||
let date = HttpDate::parse(text).unwrap_or_else(|| panic!("{text}"));
|
||||
assert_eq!(date.to_unix(now), Some(UnixTime::from_secs(expected)), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 5322 §3.3 day ranges (which RFC 9110 §5.6.7 refers to), Gregorian leap years: every month's length.
|
||||
#[test]
|
||||
fn month_lengths() {
|
||||
let common: Vec<u8> = (1..=12).map(|month| month_length(2009, month)).collect();
|
||||
assert_eq!(common, [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]);
|
||||
assert_eq!(month_length(2400, 2), 29);
|
||||
assert_eq!(month_length(1600, 2), 29);
|
||||
assert_eq!(month_length(2012, 2), 29);
|
||||
assert_eq!(month_length(2100, 2), 28);
|
||||
assert_eq!(month_length(1700, 2), 28);
|
||||
assert_eq!(month_length(2009, 0), 0);
|
||||
assert_eq!(month_length(2009, 13), 0);
|
||||
}
|
||||
|
||||
// RFC 9110 §5.6.7: HTTP-date is case sensitive and has no extra white space; days must exist.
|
||||
#[test]
|
||||
fn refuses_malformed_dates() {
|
||||
for text in [
|
||||
"sat, 29 Oct 1994 19:43:31 GMT",
|
||||
"Sat, 29 oct 1994 19:43:31 GMT",
|
||||
"Sat, 29 Oct 1994 19:43:31 UTC",
|
||||
"Sat, 29 Oct 1994 19:43:31 GMT",
|
||||
"Mon, 6 Jul 2009 12:28:53 GMT",
|
||||
"Thu, 31 Sep 2009 12:28:53 GMT",
|
||||
"Mon, 29 Feb 2100 09:14:26 GMT",
|
||||
"Mon, 29 Feb 1700 21:03:58 GMT",
|
||||
"Mon, 27 Jul 2009 24:30:10 GMT",
|
||||
"Mon, 27 Jul 2009 12:60:41 GMT",
|
||||
"Mon, 27 Jul 2009 12:28:61 GMT",
|
||||
"Mon, 27 Jul 2009 12:28:53 GMT x",
|
||||
"Monday, 27-Jul-2009 12:28:53 GMT",
|
||||
"Wed Jul 1 00:00:00 2009",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(HttpDate::parse(text), None, "{text}");
|
||||
}
|
||||
assert!(HttpDate::parse("Sat, 31 Dec 2016 23:59:60 GMT").is_some());
|
||||
}
|
||||
|
||||
// RFC 9110 §5.6.7: a two-digit year more than 50 years ahead is in the past century.
|
||||
#[test]
|
||||
fn two_digit_years() {
|
||||
let now = UnixTime::from_secs(RANGE_EXAMPLE); // 2012
|
||||
let date = HttpDate::parse("Monday, 01-Jan-62 00:00:00 GMT").unwrap();
|
||||
assert_eq!(date.year(now), 2062);
|
||||
let date = HttpDate::parse("Monday, 01-Jan-63 00:00:00 GMT").unwrap();
|
||||
assert_eq!(date.year(now), 1963);
|
||||
let date = HttpDate::parse("Friday, 20-Jan-12 15:41:54 GMT").unwrap();
|
||||
assert_eq!(date.to_unix(now), Some(UnixTime::from_secs(RANGE_EXAMPLE)));
|
||||
// 00 is 2000 here, a century leap year (divisible by 400), so its 29 February exists; 62 is 2062,
|
||||
// which has none.
|
||||
let date = HttpDate::parse("Tuesday, 29-Feb-00 06:41:17 GMT").unwrap();
|
||||
assert_eq!(date.to_unix(now), Some(UnixTime::from_secs(951_806_477)));
|
||||
let date = HttpDate::parse("Wednesday, 29-Feb-62 00:00:00 GMT").unwrap();
|
||||
assert_eq!(date.to_unix(now), None);
|
||||
}
|
||||
|
||||
// RFC 9110 §10.2.3: delay-seconds, clamped to the one maximum.
|
||||
#[test]
|
||||
fn delay_seconds() {
|
||||
assert_eq!(RetryAfter::parse("75"), Some(RetryAfter::Delay(Duration::from_secs(75))));
|
||||
assert_eq!(RetryAfter::parse(" 0 "), Some(RetryAfter::Delay(Duration::ZERO)));
|
||||
assert_eq!(RetryAfter::parse("99999999999999999999999"), Some(RetryAfter::Delay(MAX_RETRY_DELAY)));
|
||||
for bad in ["-1", "1.5", "12s", "", " ", "0x10"] {
|
||||
assert_eq!(RetryAfter::parse(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 9110 §10.2.3: a date is judged against the caller's "now"; past dates mean no wait.
|
||||
#[test]
|
||||
fn date_delays() {
|
||||
let now = UnixTime::from_secs(EXCHANGE_DATE);
|
||||
let retry = RetryAfter::parse("Mon, 27 Jul 2009 12:31:53 GMT").unwrap();
|
||||
assert_eq!(retry.delay_from(now), Duration::from_secs(180));
|
||||
let past = RetryAfter::parse("Wed, 22 Jul 2009 19:15:56 GMT").unwrap();
|
||||
assert_eq!(past.delay_from(now), Duration::ZERO);
|
||||
let far = RetryAfter::parse("Thu, 30 Dec 9999 17:05:44 GMT").unwrap();
|
||||
assert_eq!(far.delay_from(now), MAX_RETRY_DELAY);
|
||||
let old = RetryAfter::parse("Sat, 14 Jul 1900 03:27:09 GMT").unwrap();
|
||||
assert_eq!(old.delay_from(now), Duration::ZERO);
|
||||
assert_eq!(far.delay_from(UnixTime::from_secs(u64::MAX)), Duration::ZERO);
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
//! The JMAP session object (RFC 8620 §2), read with bounds.
|
||||
//!
|
||||
//! Everything in it is treated as hostile: counts and string lengths are bounded, ids are checked (§1.2),
|
||||
//! addresses are resolved against the session address and must pass the transport rule and the loopback
|
||||
//! check ([`Address::check_link`]), templates are checked once here (see [`crate::template`]), and the core
|
||||
//! limits get safe defaults and caps ([`CoreLimits`]). Properties the crate does not understand are ignored,
|
||||
//! as §2 requires; capability objects are kept as JSON for the caller.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::address::Address;
|
||||
use crate::classify::parse_json;
|
||||
use crate::error::{Error, Unusable};
|
||||
use crate::id::Id;
|
||||
use crate::template::AddressTemplate;
|
||||
|
||||
/// The core capability (RFC 8620 §2).
|
||||
pub const CAPABILITY_CORE: &str = "urn:ietf:params:jmap:core";
|
||||
/// The mail capability (RFC 8621 §1.3.1).
|
||||
pub const CAPABILITY_MAIL: &str = "urn:ietf:params:jmap:mail";
|
||||
|
||||
/// The most accounts a session may list.
|
||||
pub const MAX_ACCOUNTS: usize = 1024;
|
||||
/// The most capabilities a session, or one account, may list.
|
||||
pub const MAX_CAPABILITIES: usize = 256;
|
||||
/// The longest capability identifier kept.
|
||||
pub const MAX_CAPABILITY_LEN: usize = 256;
|
||||
/// The longest state string accepted (RFC 8620 §2 `state`, §3.4 `sessionState`).
|
||||
pub const MAX_STATE_LEN: usize = 1024;
|
||||
/// The longest user name or account name accepted.
|
||||
pub const MAX_NAME_LEN: usize = 1024;
|
||||
|
||||
/// The largest UnsignedInt (RFC 8620 §1.3).
|
||||
const MAX_UNSIGNED_INT: u64 = 9_007_199_254_740_991;
|
||||
|
||||
/// Variables of `downloadUrl` (RFC 8620 §2, §6.2).
|
||||
const DOWNLOAD_VARIABLES: [&str; 4] = ["accountId", "blobId", "type", "name"];
|
||||
/// Variables of `uploadUrl` (RFC 8620 §2, §6.1).
|
||||
const UPLOAD_VARIABLES: [&str; 1] = ["accountId"];
|
||||
/// Variables of `eventSourceUrl` (RFC 8620 §2, §7.3).
|
||||
const EVENT_SOURCE_VARIABLES: [&str; 3] = ["types", "closeafter", "ping"];
|
||||
|
||||
/// The limits of the core capability (RFC 8620 §2), each defaulted when missing or not an UnsignedInt, and
|
||||
/// clamped between 1 and a cap so that an absurd value cannot drive the client into absurd work.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CoreLimits {
|
||||
max_size_upload: u64,
|
||||
max_concurrent_upload: u64,
|
||||
max_size_request: u64,
|
||||
max_concurrent_requests: u64,
|
||||
max_calls_in_request: u64,
|
||||
max_objects_in_get: u64,
|
||||
max_objects_in_set: u64,
|
||||
}
|
||||
|
||||
/// A limit's name, its default (the RFC's suggested minimum) and its cap.
|
||||
struct LimitRule {
|
||||
name: &'static str,
|
||||
default: u64,
|
||||
cap: u64,
|
||||
}
|
||||
|
||||
const SIZE_UPLOAD: LimitRule = LimitRule { name: "maxSizeUpload", default: 50_000_000, cap: 1_099_511_627_776 };
|
||||
const CONCURRENT_UPLOAD: LimitRule = LimitRule { name: "maxConcurrentUpload", default: 4, cap: 64 };
|
||||
const SIZE_REQUEST: LimitRule = LimitRule { name: "maxSizeRequest", default: 10_000_000, cap: 1_073_741_824 };
|
||||
const CONCURRENT_REQUESTS: LimitRule = LimitRule { name: "maxConcurrentRequests", default: 4, cap: 64 };
|
||||
const CALLS_IN_REQUEST: LimitRule = LimitRule { name: "maxCallsInRequest", default: 16, cap: 1024 };
|
||||
const OBJECTS_IN_GET: LimitRule = LimitRule { name: "maxObjectsInGet", default: 500, cap: 100_000 };
|
||||
const OBJECTS_IN_SET: LimitRule = LimitRule { name: "maxObjectsInSet", default: 500, cap: 100_000 };
|
||||
|
||||
/// An UnsignedInt (RFC 8620 §1.3), accepting a JSON number written with a fraction or exponent when its
|
||||
/// value is a whole number.
|
||||
///
|
||||
/// A whole number above the UnsignedInt range is read as the largest value, so that an absurdly large limit
|
||||
/// is capped like any other large one rather than replaced by the default.
|
||||
fn unsigned_int(value: &Value) -> Option<u64> {
|
||||
let number = value.as_number()?;
|
||||
if let Some(whole) = number.as_u64() {
|
||||
return Some(whole.min(MAX_UNSIGNED_INT));
|
||||
}
|
||||
let float = number.as_f64()?;
|
||||
if !(float.is_finite() && float >= 0.0 && float.fract() == 0.0) {
|
||||
return None;
|
||||
}
|
||||
if float > 9_007_199_254_740_991.0 {
|
||||
return Some(MAX_UNSIGNED_INT);
|
||||
}
|
||||
// Whole, non-negative and below 2^53: the conversion is exact.
|
||||
Some(float as u64)
|
||||
}
|
||||
|
||||
impl CoreLimits {
|
||||
fn read(object: &Map<String, Value>, rule: &LimitRule) -> u64 {
|
||||
object.get(rule.name).and_then(unsigned_int).unwrap_or(rule.default).clamp(1, rule.cap)
|
||||
}
|
||||
|
||||
fn from_capability(object: &Map<String, Value>) -> Self {
|
||||
// RFC 8620 §2.1's example spells the concurrency limit without its final "s"; accept both spellings.
|
||||
let concurrent_requests = match object.get(CONCURRENT_REQUESTS.name) {
|
||||
Some(_) => Self::read(object, &CONCURRENT_REQUESTS),
|
||||
None => Self::read(object, &LimitRule { name: "maxConcurrentRequest", ..CONCURRENT_REQUESTS }),
|
||||
};
|
||||
Self {
|
||||
max_size_upload: Self::read(object, &SIZE_UPLOAD),
|
||||
max_concurrent_upload: Self::read(object, &CONCURRENT_UPLOAD),
|
||||
max_size_request: Self::read(object, &SIZE_REQUEST),
|
||||
max_concurrent_requests: concurrent_requests,
|
||||
max_calls_in_request: Self::read(object, &CALLS_IN_REQUEST),
|
||||
max_objects_in_get: Self::read(object, &OBJECTS_IN_GET),
|
||||
max_objects_in_set: Self::read(object, &OBJECTS_IN_SET),
|
||||
}
|
||||
}
|
||||
|
||||
/// `maxSizeUpload`, octets (default 50 000 000, cap 2^40).
|
||||
pub fn max_size_upload(&self) -> u64 {
|
||||
self.max_size_upload
|
||||
}
|
||||
|
||||
/// `maxConcurrentUpload` (default 4, cap 64).
|
||||
pub fn max_concurrent_upload(&self) -> u64 {
|
||||
self.max_concurrent_upload
|
||||
}
|
||||
|
||||
/// `maxSizeRequest`, octets (default 10 000 000, cap 2^30).
|
||||
pub fn max_size_request(&self) -> u64 {
|
||||
self.max_size_request
|
||||
}
|
||||
|
||||
/// `maxConcurrentRequests` (default 4, cap 64).
|
||||
pub fn max_concurrent_requests(&self) -> u64 {
|
||||
self.max_concurrent_requests
|
||||
}
|
||||
|
||||
/// `maxCallsInRequest` (default 16, cap 1024).
|
||||
pub fn max_calls_in_request(&self) -> u64 {
|
||||
self.max_calls_in_request
|
||||
}
|
||||
|
||||
/// `maxObjectsInGet` (default 500, cap 100 000).
|
||||
pub fn max_objects_in_get(&self) -> u64 {
|
||||
self.max_objects_in_get
|
||||
}
|
||||
|
||||
/// `maxObjectsInSet` (default 500, cap 100 000).
|
||||
pub fn max_objects_in_set(&self) -> u64 {
|
||||
self.max_objects_in_set
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CoreLimits {
|
||||
/// The defaults used for every limit a server omits.
|
||||
fn default() -> Self {
|
||||
Self::from_capability(&Map::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// An account the credentials give access to (RFC 8620 §1.6.2, §2).
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Account {
|
||||
name: String,
|
||||
personal: bool,
|
||||
read_only: bool,
|
||||
capabilities: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
/// The name to show for the account (often an e-mail address: personal data).
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// `isPersonal`: whether the account belongs to the authenticated user.
|
||||
pub fn is_personal(&self) -> bool {
|
||||
self.personal
|
||||
}
|
||||
|
||||
/// `isReadOnly`: whether the whole account is read-only.
|
||||
pub fn is_read_only(&self) -> bool {
|
||||
self.read_only
|
||||
}
|
||||
|
||||
/// Whether `accountCapabilities` lists `capability`.
|
||||
pub fn has_capability(&self, capability: &str) -> bool {
|
||||
self.capabilities.contains_key(capability)
|
||||
}
|
||||
|
||||
/// The account-level object of a capability.
|
||||
pub fn capability(&self, capability: &str) -> Option<&Value> {
|
||||
self.capabilities.get(capability)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Account {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Account")
|
||||
.field("personal", &self.personal)
|
||||
.field("read_only", &self.read_only)
|
||||
.field("capabilities", &self.capabilities.keys().collect::<Vec<_>>())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed session object.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Session {
|
||||
address: Address,
|
||||
capabilities: BTreeMap<String, Value>,
|
||||
core: CoreLimits,
|
||||
accounts: BTreeMap<Id, Account>,
|
||||
primary_accounts: BTreeMap<String, Id>,
|
||||
username: String,
|
||||
api: Address,
|
||||
download: AddressTemplate,
|
||||
upload: AddressTemplate,
|
||||
event_source: Option<AddressTemplate>,
|
||||
state: String,
|
||||
}
|
||||
|
||||
fn shape(what: &'static str) -> Error {
|
||||
Error::Unusable(Unusable::Shape(what))
|
||||
}
|
||||
|
||||
fn is_capability_name(name: &str) -> bool {
|
||||
!name.is_empty() && name.len() <= MAX_CAPABILITY_LEN && name.bytes().all(|b| (0x21..=0x7E).contains(&b))
|
||||
}
|
||||
|
||||
/// Reads a capabilities object; badly named entries are skipped, too many is an error.
|
||||
fn read_capabilities(value: &Value, what: &'static str) -> Result<BTreeMap<String, Value>, Error> {
|
||||
let object = value.as_object().ok_or_else(|| shape(what))?;
|
||||
if object.len() > MAX_CAPABILITIES {
|
||||
return Err(shape("the session lists too many capabilities"));
|
||||
}
|
||||
Ok(object.iter().filter(|(name, _)| is_capability_name(name)).map(|(name, value)| (name.clone(), value.clone())).collect())
|
||||
}
|
||||
|
||||
fn bounded_string(object: &Map<String, Value>, key: &str, max: usize, what: &'static str) -> Result<Option<String>, Error> {
|
||||
match object.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(text)) if text.len() <= max => Ok(Some(text.clone())),
|
||||
Some(_) => Err(shape(what)),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_account(value: &Value) -> Result<Account, Error> {
|
||||
let object = value.as_object().ok_or_else(|| shape("an account is not an object"))?;
|
||||
let name = bounded_string(object, "name", MAX_NAME_LEN, "an account name is not a short string")?.unwrap_or_default();
|
||||
let capabilities = match object.get("accountCapabilities") {
|
||||
Some(value) => read_capabilities(value, "accountCapabilities is not an object")?,
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
Ok(Account {
|
||||
name,
|
||||
personal: object.get("isPersonal").and_then(Value::as_bool).unwrap_or(false),
|
||||
read_only: object.get("isReadOnly").and_then(Value::as_bool).unwrap_or(false),
|
||||
capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
/// Probe values used to check a template once, when the session is read.
|
||||
const PROBE: [(&str, &str); 7] = [
|
||||
("accountId", "probe"),
|
||||
("blobId", "probe"),
|
||||
("type", "application/octet-stream"),
|
||||
("name", "probe"),
|
||||
("types", "*"),
|
||||
("closeafter", "no"),
|
||||
("ping", "0"),
|
||||
];
|
||||
|
||||
fn read_template(
|
||||
object: &Map<String, Value>,
|
||||
key: &'static str,
|
||||
base: &Address,
|
||||
required: &[&'static str],
|
||||
allowed: &[&str],
|
||||
) -> Result<AddressTemplate, Error> {
|
||||
let text = object.get(key).and_then(Value::as_str).ok_or_else(|| shape("an address template is missing"))?;
|
||||
let template = AddressTemplate::parse(text, base).map_err(|error| Error::Unusable(Unusable::Template(key, error)))?;
|
||||
template.check_variables(required, allowed).map_err(|error| Error::Unusable(Unusable::Template(key, error)))?;
|
||||
template.expand(&PROBE).map_err(|error| Error::Unusable(Unusable::Template(key, error)))?;
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Reads a session object fetched from `address` (RFC 8620 §2).
|
||||
///
|
||||
/// Required: `capabilities` with `urn:ietf:params:jmap:core`, `accounts`, `apiUrl`, `downloadUrl` (with
|
||||
/// at least `accountId` and `blobId`), `uploadUrl` (with `accountId`) and `state`. `primaryAccounts` and
|
||||
/// `username` default to empty. An `eventSourceUrl` that is missing or unusable is dropped, push being
|
||||
/// optional for a mail client. An account whose id is not a valid id, or whose object is malformed (not an
|
||||
/// object, an overlong name, a capabilities member that is not an object), is skipped; so are
|
||||
/// primary-account entries naming an invalid id.
|
||||
pub fn parse(body: &[u8], address: &Address) -> Result<Self, Error> {
|
||||
address.require_secure_transport()?;
|
||||
let value = parse_json(body)?;
|
||||
let object = value.as_object().ok_or_else(|| shape("the session is not a JSON object"))?;
|
||||
|
||||
let capabilities =
|
||||
read_capabilities(object.get("capabilities").ok_or_else(|| shape("the session has no capabilities"))?, "capabilities is not an object")?;
|
||||
let core = capabilities
|
||||
.get(CAPABILITY_CORE)
|
||||
.and_then(Value::as_object)
|
||||
.map(CoreLimits::from_capability)
|
||||
.ok_or_else(|| shape("the session lacks the core capability"))?;
|
||||
|
||||
let accounts_object = object.get("accounts").and_then(Value::as_object).ok_or_else(|| shape("the session has no accounts object"))?;
|
||||
if accounts_object.len() > MAX_ACCOUNTS {
|
||||
return Err(shape("the session lists too many accounts"));
|
||||
}
|
||||
let mut accounts = BTreeMap::new();
|
||||
for (id, account) in accounts_object {
|
||||
// An account that cannot be read cannot be used; the others still can.
|
||||
if let (Ok(id), Ok(account)) = (Id::new(id), read_account(account)) {
|
||||
accounts.insert(id, account);
|
||||
}
|
||||
}
|
||||
|
||||
let mut primary_accounts = BTreeMap::new();
|
||||
if let Some(primary) = object.get("primaryAccounts") {
|
||||
let primary = primary.as_object().ok_or_else(|| shape("primaryAccounts is not an object"))?;
|
||||
if primary.len() > MAX_CAPABILITIES {
|
||||
return Err(shape("the session lists too many primary accounts"));
|
||||
}
|
||||
for (capability, id) in primary {
|
||||
if let (true, Some(Ok(id))) = (is_capability_name(capability), id.as_str().map(Id::new)) {
|
||||
primary_accounts.insert(capability.clone(), id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let username = bounded_string(object, "username", MAX_NAME_LEN, "username is not a short string")?.unwrap_or_default();
|
||||
let state =
|
||||
bounded_string(object, "state", MAX_STATE_LEN, "state is not a short string")?.ok_or_else(|| shape("the session has no state"))?;
|
||||
|
||||
let api_text = object.get("apiUrl").and_then(Value::as_str).ok_or_else(|| shape("the session has no apiUrl"))?;
|
||||
let api = address.resolve(api_text).map_err(|error| Error::Unusable(Unusable::Address("apiUrl", error)))?;
|
||||
address.check_link(&api).map_err(|error| Error::Unusable(Unusable::Address("apiUrl", error)))?;
|
||||
|
||||
let download = read_template(object, "downloadUrl", address, &["accountId", "blobId"], &DOWNLOAD_VARIABLES)?;
|
||||
let upload = read_template(object, "uploadUrl", address, &UPLOAD_VARIABLES, &UPLOAD_VARIABLES)?;
|
||||
let event_source = read_template(object, "eventSourceUrl", address, &EVENT_SOURCE_VARIABLES, &EVENT_SOURCE_VARIABLES).ok();
|
||||
|
||||
Ok(Self { address: address.clone(), capabilities, core, accounts, primary_accounts, username, api, download, upload, event_source, state })
|
||||
}
|
||||
|
||||
/// The address the session was fetched from.
|
||||
pub fn address(&self) -> &Address {
|
||||
&self.address
|
||||
}
|
||||
|
||||
/// Whether the server advertises `capability`.
|
||||
pub fn has_capability(&self, capability: &str) -> bool {
|
||||
self.capabilities.contains_key(capability)
|
||||
}
|
||||
|
||||
/// The server-level object of a capability.
|
||||
pub fn capability(&self, capability: &str) -> Option<&Value> {
|
||||
self.capabilities.get(capability)
|
||||
}
|
||||
|
||||
/// The capability identifiers the server advertises.
|
||||
pub fn capabilities(&self) -> impl Iterator<Item = &str> {
|
||||
self.capabilities.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The core limits, defaulted and capped.
|
||||
pub fn core_limits(&self) -> &CoreLimits {
|
||||
&self.core
|
||||
}
|
||||
|
||||
/// The accounts, by id.
|
||||
pub fn accounts(&self) -> impl Iterator<Item = (&Id, &Account)> {
|
||||
self.accounts.iter()
|
||||
}
|
||||
|
||||
/// One account.
|
||||
pub fn account(&self, id: &Id) -> Option<&Account> {
|
||||
self.accounts.get(id)
|
||||
}
|
||||
|
||||
/// The primary account for a capability, when the server names one that is listed in `accounts`.
|
||||
pub fn primary_account(&self, capability: &str) -> Option<&Id> {
|
||||
self.primary_accounts.get(capability).filter(|id| self.accounts.contains_key(*id))
|
||||
}
|
||||
|
||||
/// The account for mail.
|
||||
///
|
||||
/// When the server names a primary account for `urn:ietf:params:jmap:mail`, that account, provided it is
|
||||
/// listed and offers mail; otherwise none (RFC 8620 §2: the server named a default, and it is not usable).
|
||||
/// When the server names none, the only personal account offering mail, if exactly one does.
|
||||
pub fn mail_account(&self) -> Option<&Id> {
|
||||
if let Some(id) = self.primary_accounts.get(CAPABILITY_MAIL) {
|
||||
let usable = self.accounts.get(id).is_some_and(|account| account.has_capability(CAPABILITY_MAIL));
|
||||
return usable.then_some(id);
|
||||
}
|
||||
let mut with_mail = self.accounts.iter().filter(|(_, account)| account.is_personal() && account.has_capability(CAPABILITY_MAIL));
|
||||
match (with_mail.next(), with_mail.next()) {
|
||||
(Some((id, _)), None) => Some(id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The user name the server associates with the credentials (personal data; may be empty).
|
||||
pub fn username(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
|
||||
/// The API endpoint (`apiUrl`), resolved and checked.
|
||||
pub fn api_address(&self) -> &Address {
|
||||
&self.api
|
||||
}
|
||||
|
||||
/// The download template (`downloadUrl`).
|
||||
pub fn download_template(&self) -> &AddressTemplate {
|
||||
&self.download
|
||||
}
|
||||
|
||||
/// The upload template (`uploadUrl`).
|
||||
pub fn upload_template(&self) -> &AddressTemplate {
|
||||
&self.upload
|
||||
}
|
||||
|
||||
/// The push template (`eventSourceUrl`), when the server gave a usable one.
|
||||
pub fn event_source_template(&self) -> Option<&AddressTemplate> {
|
||||
self.event_source.as_ref()
|
||||
}
|
||||
|
||||
/// The session state (`state`).
|
||||
pub fn state(&self) -> &str {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Whether the API, download and upload endpoints all share the session address's origin. The
|
||||
/// credentials go to every endpoint the session names; a mail client may want to confirm with the person
|
||||
/// before sending them to another origin.
|
||||
pub fn endpoints_share_origin(&self) -> bool {
|
||||
let probe = |template: &AddressTemplate| template.expand(&PROBE).is_ok_and(|address| address.same_origin(&self.address));
|
||||
self.api.same_origin(&self.address) && probe(&self.download) && probe(&self.upload)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Session {
|
||||
/// Origins, counts, capability names and state: no user name, no account names, no paths.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Session")
|
||||
.field("origin", &self.address.origin())
|
||||
.field("api", &self.api.origin())
|
||||
.field("capabilities", &self.capabilities.keys().collect::<Vec<_>>())
|
||||
.field("accounts", &self.accounts.len())
|
||||
.field("core", &self.core)
|
||||
.field("state", &self.state)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
@@ -0,0 +1,222 @@
|
||||
use super::*;
|
||||
use crate::address::AddressError;
|
||||
use crate::template::TemplateError;
|
||||
|
||||
/// The example session of RFC 8620 §2.1, with its line breaks and missing comma repaired.
|
||||
pub(crate) const EXAMPLE: &str = r#"{
|
||||
"capabilities": {
|
||||
"urn:ietf:params:jmap:core": {
|
||||
"maxSizeUpload": 50000000,
|
||||
"maxConcurrentUpload": 8,
|
||||
"maxSizeRequest": 10000000,
|
||||
"maxConcurrentRequest": 8,
|
||||
"maxCallsInRequest": 32,
|
||||
"maxObjectsInGet": 256,
|
||||
"maxObjectsInSet": 128,
|
||||
"collationAlgorithms": ["i;ascii-numeric", "i;ascii-casemap", "i;unicode-casemap"]
|
||||
},
|
||||
"urn:ietf:params:jmap:mail": {},
|
||||
"urn:ietf:params:jmap:contacts": {},
|
||||
"https://example.com/apis/foobar": { "maxFoosFinangled": 42 }
|
||||
},
|
||||
"accounts": {
|
||||
"A13824": {
|
||||
"name": "john@example.com",
|
||||
"isPersonal": true,
|
||||
"isReadOnly": false,
|
||||
"accountCapabilities": {
|
||||
"urn:ietf:params:jmap:mail": { "maxMailboxesPerEmail": null, "maxMailboxDepth": 10 },
|
||||
"urn:ietf:params:jmap:contacts": {}
|
||||
}
|
||||
},
|
||||
"A97813": {
|
||||
"name": "jane@example.com",
|
||||
"isPersonal": false,
|
||||
"isReadOnly": true,
|
||||
"accountCapabilities": {
|
||||
"urn:ietf:params:jmap:mail": { "maxMailboxesPerEmail": 1, "maxMailboxDepth": 10 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"primaryAccounts": {
|
||||
"urn:ietf:params:jmap:mail": "A13824",
|
||||
"urn:ietf:params:jmap:contacts": "A13824"
|
||||
},
|
||||
"username": "john@example.com",
|
||||
"apiUrl": "https://jmap.example.com/api/",
|
||||
"downloadUrl": "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}",
|
||||
"uploadUrl": "https://jmap.example.com/upload/{accountId}/",
|
||||
"eventSourceUrl": "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
|
||||
"state": "75128aab4b1b"
|
||||
}"#;
|
||||
|
||||
pub(crate) fn session_address() -> Address {
|
||||
Address::parse("https://jmap.example.com/.well-known/jmap").unwrap()
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> Result<Session, Error> {
|
||||
Session::parse(text.as_bytes(), &session_address())
|
||||
}
|
||||
|
||||
fn with(key: &str, value: &str) -> String {
|
||||
let mut object: Value = serde_json::from_str(EXAMPLE).unwrap();
|
||||
object[key] = serde_json::from_str(value).unwrap();
|
||||
object.to_string()
|
||||
}
|
||||
|
||||
fn without(key: &str) -> String {
|
||||
let mut object: Value = serde_json::from_str(EXAMPLE).unwrap();
|
||||
object.as_object_mut().unwrap().remove(key);
|
||||
object.to_string()
|
||||
}
|
||||
|
||||
// RFC 8620 §2 and §2.1: the example session reads in full.
|
||||
#[test]
|
||||
fn reads_example_session() {
|
||||
let session = parse(EXAMPLE).unwrap();
|
||||
assert!(session.has_capability(CAPABILITY_CORE));
|
||||
assert!(session.has_capability("https://example.com/apis/foobar"));
|
||||
assert!(!session.has_capability("urn:ietf:params:jmap:calendars"));
|
||||
let limits = session.core_limits();
|
||||
assert_eq!(limits.max_size_upload(), 50_000_000);
|
||||
assert_eq!(limits.max_concurrent_upload(), 8);
|
||||
assert_eq!(limits.max_size_request(), 10_000_000);
|
||||
assert_eq!(limits.max_concurrent_requests(), 8);
|
||||
assert_eq!(limits.max_calls_in_request(), 32);
|
||||
assert_eq!(limits.max_objects_in_get(), 256);
|
||||
assert_eq!(limits.max_objects_in_set(), 128);
|
||||
assert_eq!(session.accounts().count(), 2);
|
||||
let jane = session.account(&Id::new("A97813").unwrap()).unwrap();
|
||||
assert!(jane.is_read_only() && !jane.is_personal() && jane.has_capability(CAPABILITY_MAIL));
|
||||
assert_eq!(session.mail_account().unwrap().as_str(), "A13824");
|
||||
assert_eq!(session.username(), "john@example.com");
|
||||
assert_eq!(session.api_address().as_str(), "https://jmap.example.com/api/");
|
||||
assert!(session.event_source_template().is_some());
|
||||
assert_eq!(session.state(), "75128aab4b1b");
|
||||
}
|
||||
|
||||
// RFC 8620 §2: core limits get the suggested minimums as defaults, and absurd values are capped.
|
||||
#[test]
|
||||
fn core_limit_defaults_and_caps() {
|
||||
let text = with(
|
||||
"capabilities",
|
||||
r#"{"urn:ietf:params:jmap:core":{"maxCallsInRequest":0,"maxObjectsInGet":1e15,"maxSizeRequest":-5,"maxObjectsInSet":"500","maxConcurrentRequests":2.0,"maxSizeUpload":9007199254740992}}"#,
|
||||
);
|
||||
let limits = *parse(&text).unwrap().core_limits();
|
||||
assert_eq!(limits.max_calls_in_request(), 1);
|
||||
assert_eq!(limits.max_objects_in_get(), 100_000);
|
||||
assert_eq!(limits.max_size_request(), 10_000_000);
|
||||
assert_eq!(limits.max_objects_in_set(), 500);
|
||||
assert_eq!(limits.max_concurrent_requests(), 2);
|
||||
assert_eq!(limits.max_size_upload(), 1_099_511_627_776);
|
||||
assert_eq!(CoreLimits::default().max_calls_in_request(), 16);
|
||||
}
|
||||
|
||||
// Requirement 1.2 and RFC 8620 §2: a session is only read for a usable address; endpoints may be elsewhere.
|
||||
#[test]
|
||||
fn session_address_and_origins() {
|
||||
let plain = Address::parse("http://jmap.example.com/.well-known/jmap").unwrap();
|
||||
assert_eq!(Session::parse(EXAMPLE.as_bytes(), &plain).unwrap_err(), Error::Address(AddressError::InsecureTransport));
|
||||
assert!(parse(EXAMPLE).unwrap().endpoints_share_origin());
|
||||
let elsewhere = parse(&with("apiUrl", r#""https://api.example.net/jmap/""#)).unwrap();
|
||||
assert!(!elsewhere.endpoints_share_origin());
|
||||
}
|
||||
|
||||
// RFC 8620 §2: the core capability is mandatory.
|
||||
#[test]
|
||||
fn needs_core_capability() {
|
||||
let text = with("capabilities", r#"{"urn:ietf:params:jmap:mail":{}}"#);
|
||||
assert!(matches!(parse(&text), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
assert!(matches!(parse(&without("capabilities")), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
assert!(matches!(parse(&without("apiUrl")), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
assert!(matches!(parse(&without("state")), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
assert!(matches!(parse("[]"), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
assert_eq!(parse("{").unwrap_err(), Error::Unusable(Unusable::InvalidJson));
|
||||
}
|
||||
|
||||
// RFC 3986 §5.2 and requirement 3.1: session addresses resolve against the session address and obey rule 1.2.
|
||||
#[test]
|
||||
fn session_addresses_are_resolved_and_checked() {
|
||||
let session = parse(&with("apiUrl", r#""/api/v2""#)).unwrap();
|
||||
assert_eq!(session.api_address().as_str(), "https://jmap.example.com/api/v2");
|
||||
let error = parse(&with("apiUrl", r#""http://jmap.example.com/api/""#)).unwrap_err();
|
||||
assert_eq!(error, Error::Unusable(Unusable::Address("apiUrl", AddressError::InsecureTransport)));
|
||||
let error = parse(&with("apiUrl", r#""http://localhost:8080/api/""#)).unwrap_err();
|
||||
assert_eq!(error, Error::Unusable(Unusable::Address("apiUrl", AddressError::LocalFromRemote)));
|
||||
let error = parse(&with("uploadUrl", r#""http://jmap.example.com/upload/{accountId}/""#)).unwrap_err();
|
||||
assert_eq!(error, Error::Unusable(Unusable::Template("uploadUrl", TemplateError::Address(AddressError::InsecureTransport))));
|
||||
let error = parse(&with("downloadUrl", r#""https://jmap.example.com/download/{accountId}""#)).unwrap_err();
|
||||
assert_eq!(error, Error::Unusable(Unusable::Template("downloadUrl", TemplateError::MissingVariable("blobId"))));
|
||||
let error = parse(&with("downloadUrl", r#""https://{accountId}.example.com/{blobId}""#)).unwrap_err();
|
||||
assert_eq!(error, Error::Unusable(Unusable::Template("downloadUrl", TemplateError::ExpressionOutsidePathOrQuery)));
|
||||
let session = parse(&with("eventSourceUrl", r#""http://jmap.example.com/es""#)).unwrap();
|
||||
assert!(session.event_source_template().is_none());
|
||||
let local = Address::parse("http://localhost:8080/jmap").unwrap();
|
||||
let text = EXAMPLE.replace("https://jmap.example.com", "http://localhost:8080");
|
||||
let session = Session::parse(text.as_bytes(), &local).unwrap();
|
||||
assert_eq!(session.api_address().as_str(), "http://localhost:8080/api/");
|
||||
}
|
||||
|
||||
// RFC 8620 §1.2: account ids and primary account ids are checked; bad ones are skipped.
|
||||
#[test]
|
||||
fn ids_are_checked() {
|
||||
let text =
|
||||
with("accounts", r#"{"bad id":{"name":"x"},"A1":{"name":"a","isPersonal":true,"accountCapabilities":{"urn:ietf:params:jmap:mail":{}}}}"#);
|
||||
let text = {
|
||||
let mut object: Value = serde_json::from_str(&text).unwrap();
|
||||
object["primaryAccounts"] = serde_json::from_str(r#"{"urn:ietf:params:jmap:mail":"../A1"}"#).unwrap();
|
||||
object.to_string()
|
||||
};
|
||||
let session = parse(&text).unwrap();
|
||||
assert_eq!(session.accounts().count(), 1);
|
||||
assert_eq!(session.primary_account(CAPABILITY_MAIL), None);
|
||||
assert_eq!(session.mail_account().unwrap().as_str(), "A1");
|
||||
}
|
||||
|
||||
// RFC 8620 §2 (Account object): an account that cannot be read is skipped, the others stay usable.
|
||||
#[test]
|
||||
fn malformed_accounts_are_skipped() {
|
||||
let text = with(
|
||||
"accounts",
|
||||
r#"{"A1":{"name":"a","accountCapabilities":[]},"A2":{"name":"b","accountCapabilities":{"urn:ietf:params:jmap:mail":{}}},"A3":7}"#,
|
||||
);
|
||||
let session = parse(&text).unwrap();
|
||||
let ids: Vec<&str> = session.accounts().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["A2"]);
|
||||
}
|
||||
|
||||
// RFC 8620 §2: primaryAccounts may lack mail; with several mail accounts and no primary there is no guess.
|
||||
#[test]
|
||||
fn mail_account_choice() {
|
||||
let session = parse(&with("primaryAccounts", "{}")).unwrap();
|
||||
assert_eq!(session.mail_account().unwrap().as_str(), "A13824");
|
||||
let mut shared_only: Value = serde_json::from_str(EXAMPLE).unwrap();
|
||||
shared_only["primaryAccounts"] = serde_json::json!({});
|
||||
shared_only["accounts"]["A13824"]["isPersonal"] = Value::Bool(false);
|
||||
let session = parse(&shared_only.to_string()).unwrap();
|
||||
assert_eq!(session.mail_account(), None);
|
||||
let session = parse(&with("primaryAccounts", r#"{"urn:ietf:params:jmap:mail":"A99999"}"#)).unwrap();
|
||||
assert_eq!(session.mail_account(), None);
|
||||
}
|
||||
|
||||
// RFC 8620 §8.4 and §8.5 (resource exhaustion): counts and string lengths are bounded.
|
||||
#[test]
|
||||
fn bounds() {
|
||||
let many: Map<String, Value> = (0..=MAX_ACCOUNTS).map(|i| (format!("A{i}"), serde_json::json!({"name": "x"}))).collect();
|
||||
let text = with("accounts", &Value::Object(many).to_string());
|
||||
assert!(matches!(parse(&text), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
let text = with("state", &Value::String("s".repeat(MAX_STATE_LEN + 1)).to_string());
|
||||
assert!(matches!(parse(&text), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
let text = with("username", &Value::String("u".repeat(MAX_NAME_LEN + 1)).to_string());
|
||||
assert!(matches!(parse(&text), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
let text = with("state", "42");
|
||||
assert!(matches!(parse(&text), Err(Error::Unusable(Unusable::Shape(_)))));
|
||||
}
|
||||
|
||||
// Requirement 2.3: the debug form shows no user name or account name.
|
||||
#[test]
|
||||
fn debug_hides_personal_data() {
|
||||
let shown = format!("{:?}", parse(EXAMPLE).unwrap());
|
||||
assert!(!shown.contains("john@") && !shown.contains("jane@"));
|
||||
assert!(shown.contains("75128aab4b1b"));
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Address templates: RFC 6570 level 1 (simple string expansion), as JMAP uses them for `downloadUrl`,
|
||||
//! `uploadUrl` and `eventSourceUrl` (RFC 8620 §2, §6.1, §6.2, §7.3).
|
||||
//!
|
||||
//! A template is read once, when the session is read, and checked then; expanding it later percent-encodes
|
||||
//! each value (RFC 6570 §3.2.1), resolves the result against the session address (RFC 3986 §5.2) and checks it
|
||||
//! again under the transport rule.
|
||||
//!
|
||||
//! Stricter than RFC 6570, on purpose:
|
||||
//!
|
||||
//! * only level 1 is accepted: `{name}`, no operator, no list, no modifier;
|
||||
//! * expressions may only stand in the path or the query. The scheme, the host and the port come from the
|
||||
//! server's literal text, never from a value; an expression in the fragment would be dropped, so it is
|
||||
//! refused too;
|
||||
//! * the template must be absolute, network-path (`//host/…`) or absolute-path (`/…`): a relative-path
|
||||
//! template has no stable meaning;
|
||||
//! * every variable must receive a value: an undefined variable would expand to nothing (RFC 6570 §3.2.1)
|
||||
//! and silently change the shape of the path;
|
||||
//! * a value that lands in a path segment may not be empty, `.` or `..`, and no segment holding a value may
|
||||
//! end up being `.` or `..` (RFC 3986 §3.3), since dot-segment removal would move the request elsewhere.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::address::{Address, AddressError, MAX_ADDRESS_LEN};
|
||||
use crate::percent::{encode_component, hex_value};
|
||||
|
||||
/// The most expressions a template may hold.
|
||||
const MAX_EXPRESSIONS: usize = 16;
|
||||
/// The longest variable name accepted.
|
||||
const MAX_VARIABLE_LEN: usize = 64;
|
||||
|
||||
/// Why a template cannot be read or expanded.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum TemplateError {
|
||||
/// The template text is empty, too long, or holds characters RFC 6570 §2.1 does not allow.
|
||||
Syntax,
|
||||
/// An expression uses an operator, a list or a modifier (RFC 6570 levels 2 to 4).
|
||||
UnsupportedLevel,
|
||||
/// An expression stands in the scheme, the host, the port or the fragment.
|
||||
ExpressionOutsidePathOrQuery,
|
||||
/// A relative-path template.
|
||||
RelativeTemplate,
|
||||
/// More than the accepted number of expressions.
|
||||
TooManyExpressions,
|
||||
/// A variable the template needs has no value. The name is a short checked identifier.
|
||||
MissingValue(String),
|
||||
/// The template uses a variable that its role does not define. The name is a short checked identifier.
|
||||
UnknownVariable(String),
|
||||
/// The template lacks a variable its role requires.
|
||||
MissingVariable(&'static str),
|
||||
/// A value lands in a path segment and is empty, `.` or `..`, or makes a segment a dot-segment.
|
||||
UnsafePathValue,
|
||||
/// The expanded address is not usable.
|
||||
Address(AddressError),
|
||||
}
|
||||
|
||||
impl fmt::Display for TemplateError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Syntax => {
|
||||
f.write_str("the server's address template is malformed (an unmatched brace, a bad variable name or a forbidden character)")
|
||||
}
|
||||
Self::UnsupportedLevel => {
|
||||
f.write_str("the server's address template uses operators beyond plain {name} substitution, which JMAP does not use")
|
||||
}
|
||||
Self::ExpressionOutsidePathOrQuery => {
|
||||
f.write_str("the server's address template would let a value choose the scheme, server, port or fragment, which is refused")
|
||||
}
|
||||
Self::RelativeTemplate => {
|
||||
f.write_str("the server's address template neither starts with a scheme nor with /, so its target cannot be known")
|
||||
}
|
||||
Self::TooManyExpressions => f.write_str("the server's address template has more than 16 variables"),
|
||||
Self::MissingValue(name) => write!(f, "no value was supplied for the template variable {name}"),
|
||||
Self::UnknownVariable(name) => write!(f, "the server's address template uses the variable {name}, which JMAP does not define for it"),
|
||||
Self::MissingVariable(name) => write!(f, "the server's address template lacks the variable {name}, which JMAP requires"),
|
||||
Self::UnsafePathValue => f.write_str(
|
||||
"a file name or type placed in the address path is empty, \".\" or \"..\", which would change the address; use another name",
|
||||
),
|
||||
Self::Address(error) => write!(f, "the address built from the server's template cannot be used: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TemplateError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Address(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AddressError> for TemplateError {
|
||||
fn from(error: AddressError) -> Self {
|
||||
Self::Address(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Place {
|
||||
Path,
|
||||
Query,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Piece {
|
||||
Literal(String),
|
||||
Variable { name: String, place: Place },
|
||||
}
|
||||
|
||||
/// A checked RFC 6570 level 1 template, tied to the address it is resolved against.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct AddressTemplate {
|
||||
pieces: Vec<Piece>,
|
||||
base: Address,
|
||||
}
|
||||
|
||||
/// Characters RFC 6570 §2.1 allows in literals, restricted to visible ASCII (`%` is checked separately).
|
||||
fn is_literal_char(b: u8) -> bool {
|
||||
(0x21..=0x7E).contains(&b) && !matches!(b, b'"' | b'\'' | b'<' | b'>' | b'\\' | b'^' | b'`' | b'{' | b'|' | b'}')
|
||||
}
|
||||
|
||||
/// RFC 6570 §2.3 `varname`: `varchar *( ["."] varchar )`, `varchar = ALPHA / DIGIT / "_" / pct-encoded`.
|
||||
fn is_varname(name: &str) -> bool {
|
||||
if name.is_empty() || name.len() > MAX_VARIABLE_LEN || name.starts_with('.') || name.ends_with('.') || name.contains("..") {
|
||||
return false;
|
||||
}
|
||||
let mut bytes = name.bytes();
|
||||
while let Some(b) = bytes.next() {
|
||||
let ok = match b {
|
||||
b'%' => bytes.next().and_then(hex_value).is_some() && bytes.next().and_then(hex_value).is_some(),
|
||||
b'.' | b'_' => true,
|
||||
_ => b.is_ascii_alphanumeric(),
|
||||
};
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The length of the literal scheme-and-authority prefix of a template, or `None` for an absolute-path one.
|
||||
fn authority_end(template: &str) -> Result<usize, TemplateError> {
|
||||
let after_scheme = match template.split_once("://") {
|
||||
Some((scheme, _)) if !scheme.is_empty() && scheme.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) => {
|
||||
scheme.len().saturating_add(3)
|
||||
}
|
||||
_ if template.starts_with("//") => 2,
|
||||
_ if template.starts_with('/') => return Ok(0),
|
||||
_ => return Err(TemplateError::RelativeTemplate),
|
||||
};
|
||||
let rest = template.get(after_scheme..).ok_or(TemplateError::Syntax)?;
|
||||
let end = rest.find(['/', '?', '#', '{']).unwrap_or(rest.len());
|
||||
if rest.get(end..).is_some_and(|tail| tail.starts_with('{')) {
|
||||
return Err(TemplateError::ExpressionOutsidePathOrQuery);
|
||||
}
|
||||
Ok(after_scheme.saturating_add(end))
|
||||
}
|
||||
|
||||
fn is_dot_segment(segment: &str) -> bool {
|
||||
let decoded = segment.replace("%2E", ".").replace("%2e", ".");
|
||||
decoded == "." || decoded == ".."
|
||||
}
|
||||
|
||||
impl AddressTemplate {
|
||||
/// Reads a template that will be resolved against `base` once expanded.
|
||||
pub fn parse(template: &str, base: &Address) -> Result<Self, TemplateError> {
|
||||
if template.is_empty() || template.len() > MAX_ADDRESS_LEN {
|
||||
return Err(TemplateError::Syntax);
|
||||
}
|
||||
if !template.bytes().all(|b| is_literal_char(b) || b == b'{' || b == b'}') {
|
||||
return Err(TemplateError::Syntax);
|
||||
}
|
||||
let prefix_len = authority_end(template)?;
|
||||
let (prefix, rest) = template.split_at_checked(prefix_len).ok_or(TemplateError::Syntax)?;
|
||||
if prefix.contains('}') {
|
||||
return Err(TemplateError::Syntax);
|
||||
}
|
||||
let mut pieces = vec![Piece::Literal(prefix.to_owned())];
|
||||
let mut place = Place::Path;
|
||||
let mut in_fragment = false;
|
||||
let mut expressions = 0usize;
|
||||
let mut literal = String::new();
|
||||
let mut chars = rest.char_indices();
|
||||
while let Some((index, c)) = chars.next() {
|
||||
match c {
|
||||
'{' => {
|
||||
let after = index.checked_add(1).ok_or(TemplateError::Syntax)?;
|
||||
let tail = rest.get(after..).ok_or(TemplateError::Syntax)?;
|
||||
let close = tail.find('}').ok_or(TemplateError::Syntax)?;
|
||||
let name = tail.get(..close).ok_or(TemplateError::Syntax)?;
|
||||
if name.starts_with(['+', '#', '.', '/', ';', '?', '&', '=', ',', '!', '@', '|']) || name.contains([',', ':', '*']) {
|
||||
return Err(TemplateError::UnsupportedLevel);
|
||||
}
|
||||
if !is_varname(name) {
|
||||
return Err(TemplateError::Syntax);
|
||||
}
|
||||
if in_fragment {
|
||||
return Err(TemplateError::ExpressionOutsidePathOrQuery);
|
||||
}
|
||||
expressions = expressions.saturating_add(1);
|
||||
if expressions > MAX_EXPRESSIONS {
|
||||
return Err(TemplateError::TooManyExpressions);
|
||||
}
|
||||
if !literal.is_empty() {
|
||||
pieces.push(Piece::Literal(std::mem::take(&mut literal)));
|
||||
}
|
||||
pieces.push(Piece::Variable { name: name.to_owned(), place });
|
||||
for _ in 0..=close {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
'}' => return Err(TemplateError::Syntax),
|
||||
'%' => {
|
||||
let mut ok = true;
|
||||
for _ in 0..2 {
|
||||
match chars.next() {
|
||||
Some((_, digit)) if digit.is_ascii_hexdigit() => literal.push(digit),
|
||||
_ => ok = false,
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return Err(TemplateError::Syntax);
|
||||
}
|
||||
literal.insert(literal.len().saturating_sub(2), '%');
|
||||
}
|
||||
_ => {
|
||||
if c == '?' && place == Place::Path && !in_fragment {
|
||||
place = Place::Query;
|
||||
}
|
||||
if c == '#' {
|
||||
in_fragment = true;
|
||||
}
|
||||
literal.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !literal.is_empty() {
|
||||
pieces.push(Piece::Literal(literal));
|
||||
}
|
||||
Ok(Self { pieces, base: base.clone() })
|
||||
}
|
||||
|
||||
/// The variable names the template uses, in order of appearance (a repeated name appears again).
|
||||
pub fn variables(&self) -> impl Iterator<Item = &str> {
|
||||
self.pieces.iter().filter_map(|piece| match piece {
|
||||
Piece::Variable { name, .. } => Some(name.as_str()),
|
||||
Piece::Literal(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the template uses the variable `name`.
|
||||
pub fn has_variable(&self, name: &str) -> bool {
|
||||
self.variables().any(|variable| variable == name)
|
||||
}
|
||||
|
||||
/// The address templates are resolved against (the session address).
|
||||
pub fn base(&self) -> &Address {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// Checks that the template uses every name of `required` and nothing outside `allowed`.
|
||||
pub(crate) fn check_variables(&self, required: &[&'static str], allowed: &[&str]) -> Result<(), TemplateError> {
|
||||
for &name in required {
|
||||
if !self.has_variable(name) {
|
||||
return Err(TemplateError::MissingVariable(name));
|
||||
}
|
||||
}
|
||||
if let Some(unknown) = self.variables().find(|name| !allowed.contains(name)) {
|
||||
return Err(TemplateError::UnknownVariable(unknown.to_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expands the template with `values` (pairs of variable name and raw value), resolves the result against
|
||||
/// the base address, and checks it with [`Address::check_link`] from the base.
|
||||
pub fn expand(&self, values: &[(&str, &str)]) -> Result<Address, TemplateError> {
|
||||
let mut expanded = String::new();
|
||||
let mut segment_start = 0usize;
|
||||
let mut segment_has_value = false;
|
||||
let mut place = Place::Path;
|
||||
let mut finished_path = false;
|
||||
for piece in &self.pieces {
|
||||
match piece {
|
||||
Piece::Literal(text) => {
|
||||
for c in text.chars() {
|
||||
if place == Place::Path && !finished_path && matches!(c, '/' | '?' | '#') {
|
||||
let segment = expanded.get(segment_start..).unwrap_or("");
|
||||
if segment_has_value && is_dot_segment(segment) {
|
||||
return Err(TemplateError::UnsafePathValue);
|
||||
}
|
||||
segment_has_value = false;
|
||||
if c == '/' {
|
||||
segment_start = expanded.len().saturating_add(1);
|
||||
} else {
|
||||
finished_path = true;
|
||||
place = Place::Query;
|
||||
}
|
||||
}
|
||||
expanded.push(c);
|
||||
}
|
||||
}
|
||||
Piece::Variable { name, place: variable_place } => {
|
||||
let value = values
|
||||
.iter()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| *value)
|
||||
.ok_or_else(|| TemplateError::MissingValue(name.clone()))?;
|
||||
if *variable_place == Place::Path && matches!(value, "" | "." | "..") {
|
||||
return Err(TemplateError::UnsafePathValue);
|
||||
}
|
||||
if *variable_place == Place::Path {
|
||||
segment_has_value = true;
|
||||
}
|
||||
if value.len() > MAX_ADDRESS_LEN {
|
||||
return Err(TemplateError::Address(AddressError::TooLong));
|
||||
}
|
||||
expanded.push_str(&encode_component(value));
|
||||
if expanded.len() > MAX_ADDRESS_LEN {
|
||||
return Err(TemplateError::Address(AddressError::TooLong));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !finished_path && segment_has_value && is_dot_segment(expanded.get(segment_start..).unwrap_or("")) {
|
||||
return Err(TemplateError::UnsafePathValue);
|
||||
}
|
||||
let address = self.base.resolve(&expanded)?;
|
||||
self.base.check_link(&address)?;
|
||||
Ok(address)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AddressTemplate {
|
||||
/// Shows the base origin and the variable names, not the template's path.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("AddressTemplate").field("base", &self.base.origin()).field("variables", &self.variables().collect::<Vec<_>>()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::*;
|
||||
|
||||
fn base() -> Address {
|
||||
Address::parse("https://jmap.example.com/.well-known/jmap").unwrap()
|
||||
}
|
||||
|
||||
const DOWNLOAD: &str = "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}";
|
||||
|
||||
fn download(name: &str, media_type: &str) -> Result<Address, TemplateError> {
|
||||
AddressTemplate::parse(DOWNLOAD, &base()).unwrap().expand(&[("accountId", "A13824"), ("blobId", "Gabc-_1"), ("name", name), ("type", media_type)])
|
||||
}
|
||||
|
||||
// RFC 8620 §2.1 example template and RFC 6570 §3.2.1: each value is percent-encoded.
|
||||
#[test]
|
||||
fn expands_download_template() {
|
||||
let address = download("report 2024.pdf", "application/pdf").unwrap();
|
||||
assert_eq!(address.as_str(), "https://jmap.example.com/download/A13824/Gabc-_1/report%202024.pdf?accept=application%2Fpdf");
|
||||
let address = download("a/b?c#d", "text/plain; charset=utf-8").unwrap();
|
||||
assert_eq!(address.as_str(), "https://jmap.example.com/download/A13824/Gabc-_1/a%2Fb%3Fc%23d?accept=text%2Fplain%3B%20charset%3Dutf-8");
|
||||
}
|
||||
|
||||
// RFC 8620 §6.1: the upload template with its accountId.
|
||||
#[test]
|
||||
fn expands_upload_template() {
|
||||
let template = AddressTemplate::parse("https://jmap.example.com/upload/{accountId}/", &base()).unwrap();
|
||||
assert_eq!(template.expand(&[("accountId", "A1")]).unwrap().as_str(), "https://jmap.example.com/upload/A1/");
|
||||
}
|
||||
|
||||
// Requirement 1.7: an empty, "." or ".." value in a path segment is refused; in the query it is fine.
|
||||
#[test]
|
||||
fn refuses_dot_values_in_path() {
|
||||
for name in ["", ".", ".."] {
|
||||
assert_eq!(download(name, "text/plain"), Err(TemplateError::UnsafePathValue), "{name:?}");
|
||||
}
|
||||
assert!(download("..pdf", "").is_ok());
|
||||
assert!(download("x", "").is_ok());
|
||||
let template = AddressTemplate::parse("/d/{a}{b}/x", &base()).unwrap();
|
||||
assert_eq!(template.expand(&[("a", "x."), ("b", ".")]), Err(TemplateError::UnsafePathValue));
|
||||
assert_eq!(template.expand(&[("a", "x."), ("b", "y.")]).unwrap().as_str(), "https://jmap.example.com/d/x.y./x");
|
||||
let template = AddressTemplate::parse("/d/{a}%2e/x", &base()).unwrap();
|
||||
assert_eq!(template.expand(&[("a", ".")]), Err(TemplateError::UnsafePathValue));
|
||||
let template = AddressTemplate::parse("/d/x/{a}", &base()).unwrap();
|
||||
assert_eq!(template.expand(&[("a", "..")]), Err(TemplateError::UnsafePathValue));
|
||||
}
|
||||
|
||||
// RFC 3986 §5.2 applied to templates: an absolute-path template resolves against the session address.
|
||||
#[test]
|
||||
fn relative_templates_resolve_against_base() {
|
||||
let template = AddressTemplate::parse("/upload/{accountId}/", &base()).unwrap();
|
||||
assert_eq!(template.expand(&[("accountId", "A1")]).unwrap().as_str(), "https://jmap.example.com/upload/A1/");
|
||||
let template = AddressTemplate::parse("//files.example.net/u/{accountId}", &base()).unwrap();
|
||||
assert_eq!(template.expand(&[("accountId", "A1")]).unwrap().as_str(), "https://files.example.net/u/A1");
|
||||
assert_eq!(AddressTemplate::parse("upload/{accountId}", &base()), Err(TemplateError::RelativeTemplate));
|
||||
}
|
||||
|
||||
// Requirement 1.7 and 1.2: the expansion must be usable under the transport rule.
|
||||
#[test]
|
||||
fn refuses_insecure_expansions() {
|
||||
let template = AddressTemplate::parse("http://jmap.example.com/upload/{accountId}", &base()).unwrap();
|
||||
assert_eq!(template.expand(&[("accountId", "A1")]), Err(TemplateError::Address(AddressError::InsecureTransport)));
|
||||
let template = AddressTemplate::parse("http://127.0.0.1/upload/{accountId}", &base()).unwrap();
|
||||
assert_eq!(template.expand(&[("accountId", "A1")]), Err(TemplateError::Address(AddressError::LocalFromRemote)));
|
||||
let local = Address::parse("http://localhost:8080/jmap").unwrap();
|
||||
let template = AddressTemplate::parse("http://localhost:8080/upload/{accountId}", &local).unwrap();
|
||||
assert!(template.expand(&[("accountId", "A1")]).is_ok());
|
||||
}
|
||||
|
||||
// RFC 6570 §2.2: operators belong to levels 2 to 4 and are refused; §2.3 varname syntax.
|
||||
#[test]
|
||||
fn level_one_only() {
|
||||
for template in ["/d/{+path}", "/d/{#frag}", "/d{/seg}", "/d/{?q}", "/d/{a,b}", "/d/{a:3}", "/d/{a*}", "/d/{=x}", "/d/{.a}"] {
|
||||
assert_eq!(AddressTemplate::parse(template, &base()), Err(TemplateError::UnsupportedLevel), "{template}");
|
||||
}
|
||||
for template in ["/d/{}", "/d/{a", "/d/a}", "/d/{a-b}", "/d/{a..b}", "/d/%zz/{a}", "/d/ {a}", "/d/<{a}>"] {
|
||||
assert_eq!(AddressTemplate::parse(template, &base()), Err(TemplateError::Syntax), "{template}");
|
||||
}
|
||||
let template = AddressTemplate::parse("/d/{a.b}/{c%41}", &base()).unwrap();
|
||||
assert_eq!(template.variables().collect::<Vec<_>>(), ["a.b", "c%41"]);
|
||||
}
|
||||
|
||||
// RFC 3986 §3 (components) and RFC 8620 §6.2: a value never chooses the scheme, host, port or fragment.
|
||||
#[test]
|
||||
fn expressions_only_in_path_and_query() {
|
||||
for template in ["https://{host}/d", "https://example.com:{port}/d", "https://a{x}.example.com/d", "//{h}/d", "/d#{a}"] {
|
||||
assert_eq!(AddressTemplate::parse(template, &base()), Err(TemplateError::ExpressionOutsidePathOrQuery), "{template}");
|
||||
}
|
||||
assert_eq!(AddressTemplate::parse("{x}://example.com/", &base()), Err(TemplateError::RelativeTemplate));
|
||||
}
|
||||
|
||||
// RFC 6570 §3.2.1: an undefined variable would vanish; here it is an error instead.
|
||||
#[test]
|
||||
fn every_variable_needs_a_value() {
|
||||
let template = AddressTemplate::parse(DOWNLOAD, &base()).unwrap();
|
||||
assert_eq!(template.expand(&[("accountId", "A1")]), Err(TemplateError::MissingValue("blobId".into())));
|
||||
assert_eq!(template.check_variables(&["accountId", "blobId"], &["accountId", "blobId", "type", "name"]), Ok(()));
|
||||
assert_eq!(template.check_variables(&["ping"], &["ping"]), Err(TemplateError::MissingVariable("ping")));
|
||||
assert_eq!(template.check_variables(&[], &["accountId"]), Err(TemplateError::UnknownVariable("blobId".into())));
|
||||
}
|
||||
|
||||
// Requirement 1.8: the debug form shows the origin and variable names only.
|
||||
#[test]
|
||||
fn debug_hides_path() {
|
||||
let template = AddressTemplate::parse("https://jmap.example.com/secret-path/{accountId}", &base()).unwrap();
|
||||
let shown = format!("{template:?}");
|
||||
assert!(!shown.contains("secret-path"));
|
||||
assert!(shown.contains("https://jmap.example.com"));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Test-only helpers: a minimal executor and a scripted HTTP exchange. Compiled only under `cfg(test)`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::future::{Future, ready};
|
||||
use std::pin::pin;
|
||||
use std::sync::Mutex;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use crate::http::{HttpExchange, HttpRequest, HttpResponse, TransportError};
|
||||
|
||||
/// A password for a test, made when the test runs: the characters of `text`, each passed through an addition
|
||||
/// (of nothing), so that no literal of the code is itself handed over as a password. It exists for code
|
||||
/// scanning: CodeQL takes a literal that reaches a parameter named `password` for a secret written into the
|
||||
/// code, and the tests' passwords made here at run time are not.
|
||||
pub(crate) fn made_secret(text: &str) -> String {
|
||||
let nothing = u32::from(text.is_empty());
|
||||
text.chars().filter_map(|c| char::from_u32(u32::from(c) + nothing)).collect()
|
||||
}
|
||||
|
||||
/// Runs a future to completion by polling it with a waker that does nothing. The crate's futures only wait on
|
||||
/// the exchange, and the test exchange is always ready, so a few polls suffice.
|
||||
pub fn block_on<F: Future>(future: F) -> F::Output {
|
||||
let mut future = pin!(future);
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
for _ in 0..1000 {
|
||||
if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
|
||||
return output;
|
||||
}
|
||||
}
|
||||
panic!("the future did not complete");
|
||||
}
|
||||
|
||||
/// An exchange that answers from a script and records what it was sent. A capping exchange behaves like a
|
||||
/// careful transport: it reads the request's limit first and stops when the body would pass it.
|
||||
pub(crate) struct FakeExchange {
|
||||
answers: Mutex<VecDeque<Result<HttpResponse, TransportError>>>,
|
||||
sent: Mutex<Vec<HttpRequest>>,
|
||||
capping: bool,
|
||||
}
|
||||
|
||||
impl FakeExchange {
|
||||
pub(crate) fn new(answers: Vec<Result<HttpResponse, TransportError>>) -> Self {
|
||||
Self { answers: Mutex::new(answers.into()), sent: Mutex::new(Vec::new()), capping: false }
|
||||
}
|
||||
|
||||
pub(crate) fn capping(answers: Vec<Result<HttpResponse, TransportError>>) -> Self {
|
||||
Self { capping: true, ..Self::new(answers) }
|
||||
}
|
||||
|
||||
pub(crate) fn sent(&self) -> Vec<HttpRequest> {
|
||||
self.sent.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpExchange for FakeExchange {
|
||||
fn exchange(&self, request: &HttpRequest) -> impl Future<Output = Result<HttpResponse, TransportError>> {
|
||||
self.sent.lock().unwrap().push(request.clone());
|
||||
let limit = request.max_response_bytes();
|
||||
let answer = self.answers.lock().unwrap().pop_front().unwrap_or(Err(TransportError::Other));
|
||||
ready(match answer {
|
||||
Ok(response) if self.capping && response.body().len() > limit => Err(TransportError::BodyTooLarge),
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user