diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index becfccf8..aaa2d0ba 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -553,15 +553,24 @@ impl Capability { } } -impl<'de> Deserialize<'de> for CapabilityIds { +// inbuxa: a request's `using`. A capability the server doesn't know is kept, +// not a parse error, so the request fails with unknownCapability naming it +// (RFC 8620, section 3.6.1) instead of notRequest. +#[derive(Debug, Clone, Default)] +pub struct Using { + pub ids: CapabilityIds, + pub unknown: Option, +} + +impl<'de> Deserialize<'de> for Using { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - struct CapabilityIdsVisitor; + struct UsingVisitor; - impl<'de> serde::de::Visitor<'de> for CapabilityIdsVisitor { - type Value = CapabilityIds; + impl<'de> serde::de::Visitor<'de> for UsingVisitor { + type Value = Using; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("an array of capability strings") @@ -571,23 +580,23 @@ impl<'de> Deserialize<'de> for CapabilityIds { where A: serde::de::SeqAccess<'de>, { - let mut capability_flags = 0u32; + let mut using = Using::default(); while let Some(capability_str) = seq.next_element::>()? { - let capability = - Capability::parse(capability_str.as_ref()).ok_or_else(|| { - serde::de::Error::custom(format!( - "Unknown capability: {capability_str:?}" - )) - })?; - - capability_flags |= capability as u32; + match Capability::parse(capability_str.as_ref()) { + Some(capability) => using.ids.0 |= capability as u32, + None => { + if using.unknown.is_none() { + using.unknown = Some(capability_str.into_owned()); + } + } + } } - Ok(CapabilityIds(capability_flags)) + Ok(using) } } - deserializer.deserialize_seq(CapabilityIdsVisitor) + deserializer.deserialize_seq(UsingVisitor) } } diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 9a366102..9b54a3c6 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -61,6 +61,8 @@ where #[derive(Debug)] pub struct Request<'x> { pub using: CapabilityIds, + // inbuxa: the first capability in `using` the server doesn't know. + pub unknown_capability: Option, pub method_calls: Vec>>, pub created_ids: Option>, } @@ -298,6 +300,7 @@ impl Default for Request<'_> { fn default() -> Self { Request { using: CapabilityIds::default(), + unknown_capability: None, method_calls: Vec::new(), created_ids: None, } diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index b93f3e2f..c970b083 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -12,7 +12,7 @@ use super::{ }; use crate::request::{ CopyRequestMethod, GetRequestMethod, ParseRequestMethod, QueryChangesRequestMethod, - QueryRequestMethod, SetRequestMethod, deserialize::DeserializeArguments, + QueryRequestMethod, SetRequestMethod, capability::Using, deserialize::DeserializeArguments, }; use serde::{ Deserialize, Deserializer, @@ -24,6 +24,12 @@ impl<'x> Request<'x> { pub fn parse(json: &'x [u8], max_calls: usize, max_size: usize) -> trc::Result { if json.len() <= max_size { match serde_json::from_slice::(json) { + Ok(Request { + unknown_capability: Some(capability), + .. + }) => Err(trc::JmapEvent::UnknownCapability + .into_err() + .details(capability)), Ok(request) => { if request.method_calls.len() <= max_calls { Ok(request) @@ -49,7 +55,9 @@ impl<'de> DeserializeArguments<'de> for Request<'de> { { hashify::fnc_map!(key.as_bytes(), b"using" => { - self.using = map.next_value()?; + let using = map.next_value::()?; + self.using = using.ids; + self.unknown_capability = using.unknown; }, b"methodCalls" => { self.method_calls = map.next_value()?; @@ -1339,6 +1347,45 @@ mod tests { println!("{:#?}", Request::parse(TEST2.as_bytes(), 10, 10240)); } + // inbuxa: an unknown capability fails the request as unknownCapability, + // naming it, and a known one parses as before. + #[test] + fn unknown_capability_is_named() { + let request = r#"{"using": ["urn:ietf:params:jmap:core", "urn:example:nope", "urn:example:other"], + "methodCalls": [["Core/echo", {}, "0"]]}"#; + let err = Request::parse(request.as_bytes(), 10, 10240).unwrap_err(); + assert_eq!( + *err.as_ref(), + trc::EventType::Jmap(trc::JmapEvent::UnknownCapability) + ); + assert_eq!( + err.value(trc::Key::Details).and_then(|v| v.as_str()), + Some("urn:example:nope") + ); + + let request = r#"{"using": ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry"], + "methodCalls": [["Core/echo", {}, "0"]]}"#; + let request = Request::parse(request.as_bytes(), 10, 10240).unwrap(); + assert!( + request + .using + .contains(crate::request::capability::Capability::Stalwart) + ); + assert!(request.unknown_capability.is_none()); + } + + #[test] + fn unknown_capability_over_websocket_is_named() { + let message = r#"{"@type": "Request", "id": "r1", "using": ["urn:example:nope"], + "methodCalls": [["Core/echo", {}, "0"]]}"#; + let err = crate::request::websocket::WebSocketMessage::parse(message.as_bytes(), 10, 10240) + .unwrap_err(); + assert_eq!( + *err.as_ref(), + trc::EventType::Jmap(trc::JmapEvent::UnknownCapability) + ); + } + #[test] fn parse_method_name_with_escaped_solidus() { let request = Request::parse(TEST_ESCAPED_SOLIDUS.as_bytes(), 10, 10240) diff --git a/crates/jmap-proto/src/request/websocket.rs b/crates/jmap-proto/src/request/websocket.rs index 8726bcd8..a6bea2b2 100644 --- a/crates/jmap-proto/src/request/websocket.rs +++ b/crates/jmap-proto/src/request/websocket.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::Request; @@ -107,6 +109,16 @@ impl<'x> WebSocketMessage<'x> { pub fn parse(json: &'x [u8], max_calls: usize, max_size: usize) -> trc::Result { if json.len() <= max_size { match serde_json::from_slice::(json) { + Ok(WebSocketMessage::Request(WebSocketRequest { + request: + Request { + unknown_capability: Some(capability), + .. + }, + .. + })) => Err(trc::JmapEvent::UnknownCapability + .into_err() + .details(capability)), Ok(WebSocketMessage::Request(req)) if req.request.method_calls.len() > max_calls => { diff --git a/tools/fork/tests/__pycache__/test_privacy_check.cpython-314.pyc b/tools/fork/tests/__pycache__/test_privacy_check.cpython-314.pyc new file mode 100644 index 00000000..8d748bdd Binary files /dev/null and b/tools/fork/tests/__pycache__/test_privacy_check.cpython-314.pyc differ