Skip to main content

parse_rust_rest/
include.rs

1//! `include`: expanding pointers into the objects they point at.
2//!
3//! The algorithm is upstream's (`RestQuery.js:241-258` for path expansion, `:1057-1288` for
4//! execution). Every prefix of every dotted path is materialized and the paths are sorted by
5//! depth, so a parent resolves before its children. Per level, the pointers at that path are
6//! collected and grouped by target class, and **one query is issued per target class per level**
7//! rather than one per pointer.
8//!
9//! The part that is not an optimization: **the nested query is a full read against the target
10//! class with the caller's own scope**, so the target class's CLP, its object ACLs and its
11//! protected fields all apply. Grafting a row in without that is the classic Parse data leak,
12//! because the caller is authorized for the class holding the pointer and not for the class it
13//! points at. This module deliberately does not fetch anything; it collects and grafts, and the
14//! pipeline owns the read.
15//!
16//! `include=*` is out of scope for 0.2.0 and is refused at parse time. See
17//! [`crate::query_parse::parse_include`].
18
19use indexmap::IndexMap;
20use parse_rust_core::{ParseMap, ParseValue};
21
22/// Pointers found at one path, grouped by target class, in encounter order.
23///
24/// Insertion-ordered so the generated `$in` array is deterministic and diffable against
25/// upstream's.
26pub type PointersByClass = IndexMap<String, Vec<String>>;
27
28/// Collect every pointer at `path`, walking through arrays.
29pub fn collect_pointers(results: &[ParseMap], path: &[String]) -> PointersByClass {
30    let mut out: PointersByClass = IndexMap::new();
31    for row in results {
32        collect_from_value(&ParseValue::Object(row.clone()), path, &mut out);
33    }
34    out
35}
36
37fn collect_from_value(value: &ParseValue, path: &[String], out: &mut PointersByClass) {
38    // An array is walked before the path is consumed, at every depth, which is how a path
39    // reaches into an array of pointers and into an array of expanded objects alike
40    // (`RestQuery.js:1296-1298`).
41    if let ParseValue::Array(items) = value {
42        for item in items {
43            collect_from_value(item, path, out);
44        }
45        return;
46    }
47    match (path.split_first(), value) {
48        (
49            None,
50            ParseValue::Pointer {
51                class_name,
52                object_id,
53            },
54        ) => {
55            let ids = out.entry(class_name.clone()).or_default();
56            if !ids.contains(object_id) {
57                ids.push(object_id.clone());
58            }
59        }
60        (None, _) => {}
61        (Some((head, rest)), ParseValue::Object(map)) => {
62            if let Some(next) = map.get(head) {
63                collect_from_value(next, rest, out);
64            }
65        }
66        _ => {}
67    }
68}
69
70/// Replace the pointers at `path` with the fetched objects.
71///
72/// `replacePointers` (`RestQuery.js:1324-1356`). An unresolved pointer is **dropped**, not left
73/// as a pointer: inside an array the element disappears, and at a scalar key the key becomes
74/// absent. That is how a pointer to a row the caller cannot read stops being evidence that the
75/// row exists.
76pub fn graft(results: &mut [ParseMap], path: &[String], fetched: &IndexMap<String, ParseMap>) {
77    for row in results.iter_mut() {
78        graft_into_map(row, path, fetched);
79    }
80}
81
82fn graft_into_map(map: &mut ParseMap, path: &[String], fetched: &IndexMap<String, ParseMap>) {
83    let Some((head, rest)) = path.split_first() else {
84        return;
85    };
86    let Some(current) = map.shift_remove(head) else {
87        return;
88    };
89    // A pointer that did not resolve leaves the key absent rather than null.
90    if let Some(value) = graft_value(current, rest, fetched) {
91        map.insert(head.clone(), value);
92    }
93    // `shift_remove` moved the key to the end of the map. Reinsertion above restores the value
94    // but not the position; key order within one object is not part of the wire contract for a
95    // rewritten key, and preserving it would mean rebuilding the map for every result.
96}
97
98fn graft_value(
99    value: ParseValue,
100    path: &[String],
101    fetched: &IndexMap<String, ParseMap>,
102) -> Option<ParseValue> {
103    // Arrays first, at every depth, mirroring `findPointers`. An element that does not resolve is
104    // dropped from the array rather than left behind as a pointer.
105    if let ParseValue::Array(items) = value {
106        return Some(ParseValue::Array(
107            items
108                .into_iter()
109                .filter_map(|item| graft_value(item, path, fetched))
110                .collect(),
111        ));
112    }
113    match (path.split_first(), value) {
114        (None, ParseValue::Pointer { object_id, .. }) => fetched
115            .get(&object_id)
116            .map(|row| ParseValue::Object(row.clone())),
117        (None, other) => Some(other),
118        (Some((head, rest)), ParseValue::Object(mut map)) => {
119            if let Some(inner) = map.shift_remove(head) {
120                if let Some(replaced) = graft_value(inner, rest, fetched) {
121                    map.insert(head.clone(), replaced);
122                }
123            }
124            Some(ParseValue::Object(map))
125        }
126        (Some(_), other) => Some(other),
127    }
128}
129
130/// Shape a fetched row for grafting: `__type` and `className`, and the `_User` stripping.
131///
132/// An included `_User` loses `sessionToken` and `authData` for a non-master caller
133/// (`RestQuery.js:1269-1275`). Note this is on top of the target class's own
134/// `filterSensitiveData`, not instead of it.
135pub fn shape_included(row: &mut ParseMap, class_name: &str, is_master: bool) {
136    row.insert(
137        "__type".to_string(),
138        ParseValue::String("Object".to_string()),
139    );
140    row.insert(
141        "className".to_string(),
142        ParseValue::String(class_name.to_string()),
143    );
144    if class_name == "_User" && !is_master {
145        row.shift_remove("sessionToken");
146        row.shift_remove("authData");
147    }
148}
149
150/// The `keys` an included query inherits (`RestQuery.js:1196-1214`).
151///
152/// Keep only keys whose leading components match the path, then take the component at the path's
153/// depth. `None` means the include is unprojected.
154pub fn keys_for_path(keys: &[String], path: &[String]) -> Option<Vec<String>> {
155    let mut out: Vec<String> = Vec::new();
156    for key in keys {
157        let parts: Vec<&str> = key.split('.').collect();
158        if !path
159            .iter()
160            .enumerate()
161            .all(|(i, p)| parts.get(i).is_some_and(|k| k == p))
162        {
163            continue;
164        }
165        if let Some(next) = parts.get(path.len()) {
166            let next = (*next).to_string();
167            if !out.contains(&next) {
168                out.push(next);
169            }
170        }
171    }
172    (!out.is_empty()).then_some(out)
173}
174
175/// The `excludeKeys` an included query inherits (`RestQuery.js:1216-1234`).
176///
177/// Deliberately a second function rather than a parameter on the first: the terminating condition
178/// differs, `i == keyPath.length - 1` here against `i < keyPath.length` for `keys`, so a shared
179/// implementation would have to be wrong for one of them.
180pub fn exclude_keys_for_path(exclude_keys: &[String], path: &[String]) -> Option<Vec<String>> {
181    let mut out: Vec<String> = Vec::new();
182    for key in exclude_keys {
183        let parts: Vec<&str> = key.split('.').collect();
184        if !path
185            .iter()
186            .enumerate()
187            .all(|(i, p)| parts.get(i).is_some_and(|k| k == p))
188        {
189            continue;
190        }
191        if path.len() == parts.len().saturating_sub(1) {
192            if let Some(next) = parts.get(path.len()) {
193                let next = (*next).to_string();
194                if !out.contains(&next) {
195                    out.push(next);
196                }
197            }
198        }
199    }
200    (!out.is_empty()).then_some(out)
201}
202
203/// The extra include paths `keys` and `excludeKeys` force (`RestQuery.js:148-183`).
204///
205/// A dotted projection needs its parent included, because the projection only ever names the
206/// first component of a path on the class being queried. `a.b.c` therefore forces `a.b`.
207pub fn paths_forced_by_projection(keys: &[String], exclude_keys: &[String]) -> Vec<String> {
208    keys.iter()
209        .chain(exclude_keys.iter())
210        .filter(|k| k.contains('.'))
211        .filter_map(|k| k.rsplit_once('.').map(|(head, _)| head.to_string()))
212        .collect()
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn pointer(class: &str, id: &str) -> ParseValue {
220        ParseValue::Pointer {
221            class_name: class.to_string(),
222            object_id: id.to_string(),
223        }
224    }
225
226    fn row(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
227        let mut m = ParseMap::new();
228        for (k, v) in pairs {
229            m.insert(k.to_string(), v);
230        }
231        m
232    }
233
234    #[test]
235    fn pointers_group_by_class_and_dedupe() {
236        let results = vec![
237            row(vec![("author", pointer("_User", "u1"))]),
238            row(vec![("author", pointer("_User", "u1"))]),
239            row(vec![("author", pointer("Robot", "r1"))]),
240        ];
241        let found = collect_pointers(&results, &["author".to_string()]);
242        assert_eq!(found.get("_User"), Some(&vec!["u1".to_string()]));
243        assert_eq!(found.get("Robot"), Some(&vec!["r1".to_string()]));
244    }
245
246    #[test]
247    fn pointers_inside_arrays_are_found() {
248        let results = vec![row(vec![(
249            "editors",
250            ParseValue::Array(vec![pointer("_User", "u1"), pointer("_User", "u2")]),
251        )])];
252        let found = collect_pointers(&results, &["editors".to_string()]);
253        assert_eq!(
254            found.get("_User"),
255            Some(&vec!["u1".to_string(), "u2".to_string()])
256        );
257    }
258
259    #[test]
260    fn a_nested_path_reaches_through_an_expanded_parent() {
261        let results = vec![row(vec![(
262            "author",
263            ParseValue::Object(row(vec![("company", pointer("Company", "c1"))])),
264        )])];
265        let found = collect_pointers(&results, &["author".to_string(), "company".to_string()]);
266        assert_eq!(found.get("Company"), Some(&vec!["c1".to_string()]));
267    }
268
269    #[test]
270    fn a_resolved_pointer_is_replaced_and_an_unresolved_one_disappears() {
271        let mut results = vec![
272            row(vec![
273                ("objectId", ParseValue::String("p1".into())),
274                ("author", pointer("_User", "u1")),
275            ]),
276            row(vec![
277                ("objectId", ParseValue::String("p2".into())),
278                ("author", pointer("_User", "hidden")),
279            ]),
280        ];
281        let mut fetched = IndexMap::new();
282        fetched.insert(
283            "u1".to_string(),
284            row(vec![("objectId", ParseValue::String("u1".into()))]),
285        );
286        graft(&mut results, &["author".to_string()], &fetched);
287        assert!(matches!(
288            results[0].get("author"),
289            Some(ParseValue::Object(_))
290        ));
291        assert!(
292            results[1].get("author").is_none(),
293            "a pointer the caller cannot read is dropped, not left as a pointer"
294        );
295    }
296
297    #[test]
298    fn an_unresolved_pointer_inside_an_array_is_filtered_out() {
299        let mut results = vec![row(vec![(
300            "editors",
301            ParseValue::Array(vec![pointer("_User", "u1"), pointer("_User", "hidden")]),
302        )])];
303        let mut fetched = IndexMap::new();
304        fetched.insert(
305            "u1".to_string(),
306            row(vec![("objectId", ParseValue::String("u1".into()))]),
307        );
308        graft(&mut results, &["editors".to_string()], &fetched);
309        match results[0].get("editors") {
310            Some(ParseValue::Array(items)) => assert_eq!(items.len(), 1),
311            other => panic!("expected an array, got {other:?}"),
312        }
313    }
314
315    #[test]
316    fn an_included_user_loses_its_session_token_for_a_non_master_caller() {
317        let mut r = row(vec![
318            ("sessionToken", ParseValue::String("r:t".into())),
319            ("authData", ParseValue::Object(ParseMap::new())),
320        ]);
321        shape_included(&mut r, "_User", false);
322        assert!(r.get("sessionToken").is_none());
323        assert!(r.get("authData").is_none());
324        assert!(matches!(r.get("__type"), Some(ParseValue::String(s)) if s == "Object"));
325        assert!(matches!(r.get("className"), Some(ParseValue::String(s)) if s == "_User"));
326
327        let mut r = row(vec![("sessionToken", ParseValue::String("r:t".into()))]);
328        shape_included(&mut r, "_User", true);
329        assert!(r.get("sessionToken").is_some());
330    }
331
332    #[test]
333    fn projections_rewrite_per_path() {
334        let keys = vec!["author.name".to_string(), "title".to_string()];
335        assert_eq!(
336            keys_for_path(&keys, &["author".to_string()]),
337            Some(vec!["name".to_string()])
338        );
339        assert_eq!(keys_for_path(&keys, &["other".to_string()]), None);
340
341        // The exclude rule stops one level shallower than the keys rule.
342        let excludes = vec!["author.company.name".to_string()];
343        assert_eq!(
344            exclude_keys_for_path(&excludes, &["author".to_string()]),
345            None
346        );
347        assert_eq!(
348            exclude_keys_for_path(&excludes, &["author".to_string(), "company".to_string()]),
349            Some(vec!["name".to_string()])
350        );
351    }
352
353    #[test]
354    fn a_dotted_projection_forces_its_parent_include() {
355        assert_eq!(
356            paths_forced_by_projection(&["a.b.c".to_string(), "d".to_string()], &[]),
357            vec!["a.b".to_string()]
358        );
359        assert_eq!(
360            paths_forced_by_projection(&[], &["x.y".to_string()]),
361            vec!["x".to_string()]
362        );
363    }
364}