JMAP: an unknown capability is unknownCapability, not notRequest #210

Merged
jcoffey-dev merged 1 commits from fix/unknown-capability into main 2026-10-26 16:01:49 +00:00
5 changed files with 88 additions and 17 deletions

No files matched your search

+24 -15
View File
@@ -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<String>,
}
impl<'de> Deserialize<'de> for Using {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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::<std::borrow::Cow<str>>()? {
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)
}
}
+3
View File
@@ -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<String>,
pub method_calls: Vec<Call<RequestMethod<'x>>>,
pub created_ids: Option<HashMap<String, AnyId>>,
}
@@ -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,
}
+49 -2
View File
@@ -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<Self> {
if json.len() <= max_size {
match serde_json::from_slice::<Request>(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::<Using>()?;
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)
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<Self> {
if json.len() <= max_size {
match serde_json::from_slice::<Self>(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 =>
{