jmap_types/query.rs
1//! RFC 8620 §5.5 generic filter types for JMAP `/query` methods.
2//!
3//! Provides [`Filter`], [`FilterOperator`], and [`Operator`].
4//! Object-specific filter conditions (e.g. `EmailFilterCondition`) are
5//! defined in their respective type crates.
6//!
7//! # Filter algebra is excluded from extras preservation
8//!
9//! The filter algebra defined in this module is **intentionally not extensible**
10//! via the workspace "extras preservation" policy. See [`Filter`],
11//! [`FilterOperator`], and [`Operator`] for details. The same exclusion applies
12//! to every per-object `FilterCondition` / `Comparator` / `ComparatorProperty`
13//! type in the downstream `jmap-*-types` crates (see workspace `AGENTS.md`,
14//! bd JMAP-lbdy "Decision: filter algebra excluded").
15
16use serde::{Deserialize, Serialize};
17
18/// Logical operator for combining filter conditions (RFC 8620 §5.5).
19///
20/// # Excluded from extras preservation
21///
22/// This enum is **out of scope** for the workspace extras-preservation policy:
23/// it carries no `Other(String)` catch-all variant, and backends must
24/// dispatch on its known variants (`AND`, `OR`, `NOT`) to evaluate a filter
25/// tree. An `Other` operator would be meaningless — a server that cannot
26/// interpret the operator cannot evaluate the filter, and silently round-
27/// tripping it back to the client would yield wrong query results.
28///
29/// More broadly, filter algebra (this enum and the per-object
30/// `FilterCondition` / `Comparator` types) is excluded because unrecognised
31/// filter clauses are a query-correctness hazard: silently dropping or
32/// round-tripping a clause the server does not understand can return the
33/// wrong set of records to the client without any error signal.
34///
35/// ## What to do instead
36///
37/// **IETF-track path.** Vendors who need both capability-level declaration
38/// and filterability for custom fields should use
39/// `draft-ietf-jmap-metadata` (capability URI
40/// `urn:ietf:params:jmap:metadata`), which defines a `Metadata` / `Annotation`
41/// companion object keyed by `(relatedType, relatedId)` with schema discovery
42/// via the capability's `metadataTypes` / `maxDepth` properties and a
43/// `Metadata/query` filter. Implemented in `jmap-metadata-types`,
44/// `jmap-metadata-server`, and `jmap-metadata-client` (bd JMAP-06zp).
45///
46/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
47/// either escape the filter tree to `serde_json::Value` or fork the
48/// per-crate `FilterCondition` type. See
49/// `crate-jmap-calendars-types/PLAN.md` for the hybrid sloppy-value
50/// pattern.
51///
52/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
53#[non_exhaustive]
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
56pub enum Operator {
57 /// Logical AND: all sub-filters must match (RFC 8620 §5.5).
58 And,
59 /// Logical OR: at least one sub-filter must match (RFC 8620 §5.5).
60 Or,
61 /// Logical NOT: none of the sub-filters may match (RFC 8620 §5.5).
62 Not,
63}
64
65/// A filter node: either a logical operator combining sub-filters, or a
66/// type-specific condition object (RFC 8620 §5.5).
67///
68/// Serializes as an untagged union. The presence of the `"operator"` key
69/// distinguishes `Filter::Operator` from `Filter::Condition`.
70///
71/// **Variant ordering is critical**: `Operator` is listed before `Condition`
72/// because serde untagged tries variants in declaration order.
73/// `FilterOperator<T>` requires an `"operator"` field and fails fast without
74/// it, allowing the deserializer to fall through to `Condition(T)`.
75///
76/// # Malformed-operator hazard (silent fallthrough)
77///
78/// The untagged-enum dispatch combined with the per-crate `FilterCondition`
79/// types being structs of all-`Option` fields without
80/// `#[serde(deny_unknown_fields)]` creates a query-correctness hazard:
81/// a client clause whose `operator` key is misspelled (e.g.
82/// `{"opperator":"AND","conditions":[]}` or `{"Operator":"AND","conditions":[]}`)
83/// fails to deserialize as [`FilterOperator`] (the spelling does not match
84/// the typed field) and falls through to `Filter::Condition(T)`. The
85/// fallthrough variant then deserializes as `T::default()` (all fields
86/// `None`), which semantically means "no constraint" and therefore
87/// **matches every record**. The malformed clause produces no
88/// deserialization error and no query error — the query just silently
89/// returns the full result set.
90///
91/// `#[serde(deny_unknown_fields)]` cannot be added to the `T`
92/// implementations because it interacts badly with `#[serde(untagged)]`
93/// (see `jmap-mail-types/src/query.rs` for the in-tree warning).
94///
95/// **Server-side defenses** (the canonical mitigations live in server
96/// crates, not here):
97///
98/// 1. Validate the parsed filter tree. After deserialization, walk the
99/// tree and reject any `Filter::Condition(t)` whose `t` has every
100/// field unset — that is a "match all" pre-image and almost certainly
101/// a malformed client clause. Map to RFC 8620 §5.5
102/// `unsupportedFilter`.
103/// 2. Validate the raw JSON before / instead of relying on the typed
104/// deserialization. Look for `conditions` adjacent to a non-`operator`
105/// key, or `operator` adjacent to a non-`conditions` key, or any
106/// object containing neither `operator` nor a recognised
107/// `T::FieldName`. Reject with `unsupportedFilter`.
108///
109/// Clients writing query filters should never rely on "no-error" as a
110/// signal of acceptance; always check that the response shape matches
111/// what they asked for.
112///
113/// Regression test in this crate: see
114/// `filter_with_typoed_operator_silently_decodes_as_match_all_condition`.
115///
116/// # Excluded from extras preservation
117///
118/// This type is **out of scope** for the workspace extras-preservation
119/// policy: it carries no flatten-extras `extra` field, and the per-object
120/// condition type `T` is also expected to be non-extensible. Filter clauses
121/// the server does not understand are a query-correctness hazard — silently
122/// preserving an unrecognised clause and round-tripping it back to the
123/// client can return the wrong set of records with no error signal.
124///
125/// ## What to do instead
126///
127/// **IETF-track path.** Vendors who need both capability-level declaration
128/// and filterability for custom fields should use
129/// `draft-ietf-jmap-metadata` (capability URI
130/// `urn:ietf:params:jmap:metadata`), which defines a filterable
131/// `Metadata` / `Annotation` companion object. Implemented in `jmap-metadata-types`,
132/// `jmap-metadata-server`, and `jmap-metadata-client` (bd JMAP-06zp).
133///
134/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
135/// either escape the filter tree to `serde_json::Value` or fork the
136/// per-crate `FilterCondition` type. See
137/// `crate-jmap-calendars-types/PLAN.md` for the hybrid sloppy-value
138/// pattern.
139///
140/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
141#[non_exhaustive]
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(untagged)]
144pub enum Filter<T> {
145 /// A logical combination of sub-filters.
146 Operator(FilterOperator<T>),
147 /// A type-specific condition object.
148 Condition(T),
149}
150
151/// Logical combination of filters (RFC 8620 §5.5).
152///
153/// # Excluded from extras preservation
154///
155/// This type is **out of scope** for the workspace extras-preservation
156/// policy: it carries no flatten-extras `extra` field, and its
157/// [`Operator`] field is a closed control enum that backends must dispatch
158/// on. See [`Operator`] and [`Filter`] for the rationale and for the two
159/// recommended paths (`draft-ietf-jmap-metadata`, bd JMAP-06zp; or the
160/// pre-IETF sloppy-value escape).
161///
162/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
163#[non_exhaustive]
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct FilterOperator<T> {
166 /// Logical operator: AND, OR, or NOT.
167 pub operator: Operator,
168 /// Sub-conditions to evaluate.
169 pub conditions: Vec<Filter<T>>,
170}
171
172impl<T> FilterOperator<T> {
173 /// Create a new [`FilterOperator`] with the given logical operator and conditions.
174 pub fn new(operator: Operator, conditions: Vec<Filter<T>>) -> Self {
175 Self {
176 operator,
177 conditions,
178 }
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 // FilterCondition stub used only within this test module.
187 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188 struct Cond {
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub has_keyword: Option<String>,
191 }
192
193 /// Oracle: exercises the Filter<T> generic with a local stub type.
194 /// Adapted from the RFC 8620 §5.5 Todo/query example; `has_keyword` uses
195 /// Rust snake_case because `Cond` is a test stub, not a real JMAP type.
196 #[test]
197 fn filter_operator_or_roundtrip() {
198 let json =
199 r#"{"operator":"OR","conditions":[{"has_keyword":"music"},{"has_keyword":"video"}]}"#;
200 let f: Filter<Cond> = serde_json::from_str(json).expect("must parse");
201 match &f {
202 Filter::Operator(op) => {
203 assert_eq!(op.operator, Operator::Or);
204 assert_eq!(op.conditions.len(), 2);
205 }
206 other => panic!("expected Operator, got {other:?}"),
207 }
208 let back = serde_json::to_string(&f).expect("must serialize");
209 let f2: Filter<Cond> = serde_json::from_str(&back).expect("roundtrip");
210 assert_eq!(f, f2);
211 }
212
213 /// Oracle: a bare condition object (no "operator" key) deserializes as
214 /// Filter::Condition.
215 #[test]
216 fn filter_condition_deserialization() {
217 let json = r#"{"has_keyword":"$seen"}"#;
218 let f: Filter<Cond> = serde_json::from_str(json).expect("must parse");
219 match &f {
220 Filter::Condition(c) => assert_eq!(c.has_keyword.as_deref(), Some("$seen")),
221 other => panic!("expected Condition, got {other:?}"),
222 }
223 }
224
225 /// Oracle: Operator enum serializes as SCREAMING_SNAKE_CASE per RFC 8620 §5.5.
226 #[test]
227 fn operator_serialization() {
228 assert_eq!(serde_json::to_string(&Operator::And).unwrap(), r#""AND""#);
229 assert_eq!(serde_json::to_string(&Operator::Or).unwrap(), r#""OR""#);
230 assert_eq!(serde_json::to_string(&Operator::Not).unwrap(), r#""NOT""#);
231 }
232
233 /// Oracle: pins the malformed-operator silent-fallthrough hazard
234 /// documented on [`Filter`]. A client clause with a misspelled
235 /// `operator` field key (here `opperator`) fails to deserialize as
236 /// `Filter::Operator(FilterOperator)` (the key does not match the
237 /// typed field name) and falls through to `Filter::Condition(T)`.
238 /// Because `Cond` is a struct of all-`Option` fields without
239 /// `#[serde(deny_unknown_fields)]`, serde silently drops the
240 /// unknown `opperator` and `conditions` keys and deserializes the
241 /// clause as `Cond { has_keyword: None }` — semantically "match
242 /// any record".
243 ///
244 /// This test is a regression marker for `bd:JMAP-6xs8.7`: if a
245 /// future serde or `#[serde(untagged)]` change alters the
246 /// fallthrough behavior (e.g. starts rejecting unknown fields),
247 /// this test will fail and force a deliberate review of the
248 /// server-side defenses documented on [`Filter`]'s rustdoc.
249 ///
250 /// Server-side mitigation is out of scope for this crate; see the
251 /// "Malformed-operator hazard" section on [`Filter`] for the two
252 /// canonical defenses backends must implement.
253 #[test]
254 fn filter_with_typoed_operator_silently_decodes_as_match_all_condition() {
255 // Variant 1: lower-case `o` typoed as `opperator`.
256 let raw = r#"{"opperator":"AND","conditions":[]}"#;
257 let f: Filter<Cond> = serde_json::from_str(raw).expect(
258 "untagged enum must accept the typo as Filter::Condition — \
259 this is the silent-fallthrough hazard",
260 );
261 match f {
262 Filter::Condition(c) => assert!(
263 c.has_keyword.is_none(),
264 "fallthrough Condition must be the all-None default \
265 'match-all' shape; got: {c:?}"
266 ),
267 Filter::Operator(_) => panic!(
268 "expected silent fallthrough to Condition, got Operator \
269 — serde untagged behavior changed?"
270 ),
271 }
272
273 // Variant 2: capitalised `O` typoed as `Operator`.
274 let raw_capitalised = r#"{"Operator":"AND","conditions":[]}"#;
275 let f2: Filter<Cond> =
276 serde_json::from_str(raw_capitalised).expect("capitalised typo must also fall through");
277 match f2 {
278 Filter::Condition(c) => assert!(c.has_keyword.is_none()),
279 Filter::Operator(_) => panic!("expected silent fallthrough to Condition, got Operator"),
280 }
281 }
282
283 /// Oracle: nested AND(OR(...)) structure roundtrips correctly.
284 #[test]
285 fn nested_filter_roundtrip() {
286 let filter = Filter::Operator(FilterOperator {
287 operator: Operator::And,
288 conditions: vec![
289 Filter::Operator(FilterOperator {
290 operator: Operator::Or,
291 conditions: vec![
292 Filter::Condition(Cond {
293 has_keyword: Some("a".to_owned()),
294 }),
295 Filter::Condition(Cond {
296 has_keyword: Some("b".to_owned()),
297 }),
298 ],
299 }),
300 Filter::Condition(Cond {
301 has_keyword: Some("c".to_owned()),
302 }),
303 ],
304 });
305 let json = serde_json::to_string(&filter).expect("serialize");
306 let back: Filter<Cond> = serde_json::from_str(&json).expect("deserialize");
307 assert_eq!(filter, back);
308 }
309}