Skip to main content

fakecloud_stepfunctions/
io_processing.rs

1use serde_json::Value;
2
3/// Apply InputPath to extract a subset of the raw input.
4/// - `None` or `Some("$")` → return input unchanged
5/// - `Some("null")` handled at call site (pass `{}`)
6/// - `Some("$.foo.bar")` → extract nested field
7pub fn apply_input_path(input: &Value, path: Option<&str>) -> Value {
8    match path {
9        None | Some("$") => input.clone(),
10        Some(p) => resolve_path(input, p),
11    }
12}
13
14/// Apply OutputPath to extract a subset of the effective output.
15/// Same semantics as InputPath.
16pub fn apply_output_path(output: &Value, path: Option<&str>) -> Value {
17    match path {
18        None | Some("$") => output.clone(),
19        Some(p) => resolve_path(output, p),
20    }
21}
22
23/// Apply ResultPath to merge a state's result into the input.
24/// - `None` or `Some("$")` → result replaces input entirely
25/// - `Some("null")` → discard result, return original input
26/// - `Some("$.foo")` → set result at that path within input
27pub fn apply_result_path(input: &Value, result: &Value, path: Option<&str>) -> Value {
28    match path {
29        None | Some("$") => result.clone(),
30        Some("null") => input.clone(),
31        Some(p) => set_at_path(input, p, result),
32    }
33}
34
35/// Resolve a simple JsonPath expression against a JSON value.
36/// Supports: `$`, `$.field`, `$.field.nested`, `$.arr[0]`
37pub fn resolve_path(root: &Value, path: &str) -> Value {
38    if path == "$" {
39        return root.clone();
40    }
41
42    let path = path.strip_prefix("$.").unwrap_or(path);
43    let mut current = root;
44
45    for segment in split_path_segments(path) {
46        match segment {
47            PathSegment::Field(name) => {
48                current = match current.get(name) {
49                    Some(v) => v,
50                    None => return Value::Null,
51                };
52            }
53            PathSegment::Index(name, idx) => {
54                current = match current.get(name) {
55                    Some(v) => match v.get(idx) {
56                        Some(v) => v,
57                        None => return Value::Null,
58                    },
59                    None => return Value::Null,
60                };
61            }
62        }
63    }
64
65    current.clone()
66}
67
68/// Set a value at a simple JsonPath within a JSON structure. Handles both plain
69/// object fields (`$.a.b`) and array-index segments (`$.a[0].b`). An index
70/// segment `name[idx]` descends into element `idx` of the array stored at
71/// `name`, growing the array with nulls as needed.
72fn set_at_path(root: &Value, path: &str, value: &Value) -> Value {
73    let mut result = root.clone();
74    let path = path.strip_prefix("$.").unwrap_or(path);
75    let segments = split_path_segments(path);
76
77    fn assign(current: &mut Value, segments: &[PathSegment<'_>], value: &Value) {
78        let (segment, rest) = match segments.split_first() {
79            Some(pair) => pair,
80            None => return,
81        };
82        let is_last = rest.is_empty();
83        match segment {
84            PathSegment::Field(name) => {
85                if let Some(obj) = current.as_object_mut() {
86                    if is_last {
87                        obj.insert(name.to_string(), value.clone());
88                    } else {
89                        let child = obj
90                            .entry(name.to_string())
91                            .or_insert_with(|| serde_json::json!({}));
92                        assign(child, rest, value);
93                    }
94                }
95            }
96            PathSegment::Index(name, idx) => {
97                // Ensure `name` holds an array long enough to index `idx`.
98                if let Some(obj) = current.as_object_mut() {
99                    let arr_val = obj
100                        .entry(name.to_string())
101                        .or_insert_with(|| Value::Array(Vec::new()));
102                    if !arr_val.is_array() {
103                        *arr_val = Value::Array(Vec::new());
104                    }
105                    if let Some(arr) = arr_val.as_array_mut() {
106                        if arr.len() <= *idx {
107                            arr.resize(idx + 1, Value::Null);
108                        }
109                        if is_last {
110                            arr[*idx] = value.clone();
111                        } else {
112                            if !arr[*idx].is_object() && !arr[*idx].is_array() {
113                                arr[*idx] = serde_json::json!({});
114                            }
115                            assign(&mut arr[*idx], rest, value);
116                        }
117                    }
118                }
119            }
120        }
121    }
122
123    assign(&mut result, &segments, value);
124    result
125}
126
127enum PathSegment<'a> {
128    Field(&'a str),
129    Index(&'a str, usize),
130}
131
132fn split_path_segments(path: &str) -> Vec<PathSegment<'_>> {
133    let mut segments = Vec::new();
134    for part in path.split('.') {
135        match parse_index_segment(part) {
136            Some(segment) => segments.push(segment),
137            None => segments.push(PathSegment::Field(part)),
138        }
139    }
140    segments
141}
142
143/// Attempt to parse a `name[idx]` segment. Returns `None` (so the caller treats
144/// the part as a plain field) for any malformed bracket expression. This must
145/// never panic on adversarial input such as `"arr["` (no trailing `]`) or
146/// `"x[é"` (multibyte char where the close bracket would be), both of which are
147/// accepted by `CreateStateMachine` today.
148fn parse_index_segment(part: &str) -> Option<PathSegment<'_>> {
149    let open = part.find('[')?;
150    // Must actually be a closed `[...]` ending the segment.
151    if !part.ends_with(']') {
152        return None;
153    }
154    let close = part.len() - 1;
155    // `close` lands on the `]` byte; the index content is between the brackets.
156    // Guard against `[]` (empty) and ranges that would underflow.
157    let inner_start = open + 1;
158    if inner_start > close {
159        return None;
160    }
161    // Both `inner_start` and `close` are at ASCII bracket boundaries, so slicing
162    // here can never split a multibyte char. The slice content itself may still
163    // be non-ASCII, but it only needs to parse as a usize.
164    let idx_str = part.get(inner_start..close)?;
165    let name = part.get(..open)?;
166    let idx = idx_str.parse::<usize>().ok()?;
167    Some(PathSegment::Index(name, idx))
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use serde_json::json;
174
175    #[test]
176    fn test_resolve_path_root() {
177        let input = json!({"a": 1});
178        assert_eq!(resolve_path(&input, "$"), input);
179    }
180
181    #[test]
182    fn test_resolve_path_simple_field() {
183        let input = json!({"name": "hello", "value": 42});
184        assert_eq!(resolve_path(&input, "$.name"), json!("hello"));
185        assert_eq!(resolve_path(&input, "$.value"), json!(42));
186    }
187
188    #[test]
189    fn test_resolve_path_nested() {
190        let input = json!({"a": {"b": {"c": 99}}});
191        assert_eq!(resolve_path(&input, "$.a.b.c"), json!(99));
192    }
193
194    #[test]
195    fn test_resolve_path_missing() {
196        let input = json!({"a": 1});
197        assert_eq!(resolve_path(&input, "$.missing"), Value::Null);
198    }
199
200    #[test]
201    fn test_resolve_path_array_index() {
202        let input = json!({"items": [10, 20, 30]});
203        assert_eq!(resolve_path(&input, "$.items[0]"), json!(10));
204        assert_eq!(resolve_path(&input, "$.items[2]"), json!(30));
205    }
206
207    #[test]
208    fn test_apply_input_path_default() {
209        let input = json!({"x": 1});
210        assert_eq!(apply_input_path(&input, None), input);
211        assert_eq!(apply_input_path(&input, Some("$")), input);
212    }
213
214    #[test]
215    fn test_apply_result_path_default() {
216        let input = json!({"x": 1});
217        let result = json!({"y": 2});
218        // Default: result replaces input
219        assert_eq!(apply_result_path(&input, &result, None), result);
220        assert_eq!(apply_result_path(&input, &result, Some("$")), result);
221    }
222
223    #[test]
224    fn test_apply_result_path_null() {
225        let input = json!({"x": 1});
226        let result = json!({"y": 2});
227        // null: discard result, keep input
228        assert_eq!(apply_result_path(&input, &result, Some("null")), input);
229    }
230
231    #[test]
232    fn test_apply_result_path_nested() {
233        let input = json!({"x": 1});
234        let result = json!("hello");
235        let output = apply_result_path(&input, &result, Some("$.result"));
236        assert_eq!(output, json!({"x": 1, "result": "hello"}));
237    }
238
239    #[test]
240    fn test_set_at_path_non_object_intermediate() {
241        // When an intermediate path segment is a non-object (e.g., a number),
242        // set_at_path should not panic — it should bail out gracefully.
243        let input = json!({"x": 42});
244        let result = json!("hello");
245        let output = apply_result_path(&input, &result, Some("$.x.nested"));
246        // x is a number, can't set nested on it — should return input unchanged
247        assert_eq!(output, json!({"x": 42}));
248    }
249
250    // L8: ResultPath with an array-index segment must descend into the array,
251    // not insert a literal key like "a[0]".
252    #[test]
253    fn test_set_at_path_array_index_leaf() {
254        let input = json!({"a": [1, 2, 3]});
255        let result = json!(99);
256        let output = apply_result_path(&input, &result, Some("$.a[1]"));
257        assert_eq!(output, json!({"a": [1, 99, 3]}));
258        // No stray literal "a[1]" key was created.
259        assert!(output.get("a[1]").is_none());
260    }
261
262    #[test]
263    fn test_set_at_path_array_index_then_field() {
264        let input = json!({"items": [{"v": 1}]});
265        let result = json!("done");
266        let output = apply_result_path(&input, &result, Some("$.items[0].status"));
267        assert_eq!(output, json!({"items": [{"v": 1, "status": "done"}]}));
268    }
269
270    #[test]
271    fn test_set_at_path_array_index_grows_array() {
272        let input = json!({});
273        let result = json!("x");
274        let output = apply_result_path(&input, &result, Some("$.a[2]"));
275        assert_eq!(output, json!({"a": [null, null, "x"]}));
276    }
277
278    #[test]
279    fn test_apply_output_path() {
280        let output = json!({"a": 1, "b": 2});
281        assert_eq!(apply_output_path(&output, Some("$.a")), json!(1));
282        assert_eq!(apply_output_path(&output, None), output);
283    }
284
285    #[test]
286    fn test_resolve_path_unclosed_bracket_does_not_panic() {
287        // Malformed JSONPath: `[` with no trailing `]`. Previously this sliced
288        // `[bracket_pos + 1 .. part.len() - 1]` which underflows -> panic.
289        let input = json!({"arr": [1, 2, 3]});
290        // No field literally named "arr[" exists, so this resolves to Null,
291        // but the key requirement is that it must NOT panic.
292        assert_eq!(resolve_path(&input, "$.arr["), Value::Null);
293    }
294
295    #[test]
296    fn test_resolve_path_multibyte_after_bracket_does_not_panic() {
297        // Multibyte char where the close bracket would be. `part.len() - 1`
298        // previously landed mid-char -> "byte index is not a char boundary".
299        let input = json!({"x": [1, 2, 3]});
300        assert_eq!(resolve_path(&input, "$.x[é"), Value::Null);
301        // Also the closed-but-multibyte-inner case.
302        assert_eq!(resolve_path(&input, "$.x[é]"), Value::Null);
303    }
304
305    #[test]
306    fn test_resolve_path_empty_brackets_do_not_panic() {
307        let input = json!({"x": [1, 2, 3]});
308        assert_eq!(resolve_path(&input, "$.x[]"), Value::Null);
309    }
310
311    #[test]
312    fn test_resolve_path_bracket_only_segment() {
313        // A bare `[` as an entire segment.
314        let input = json!({"a": 1});
315        assert_eq!(resolve_path(&input, "$.["), Value::Null);
316        assert_eq!(resolve_path(&input, "$.]"), Value::Null);
317    }
318
319    #[test]
320    fn test_split_path_segments_well_formed_index_still_works() {
321        // Ensure the hardening did not break the happy path.
322        let input = json!({"items": [10, 20, 30]});
323        assert_eq!(resolve_path(&input, "$.items[1]"), json!(20));
324        let nested = json!({"a": {"b": [{"c": 7}]}});
325        assert_eq!(resolve_path(&nested, "$.a.b[0].c"), json!(7));
326    }
327
328    #[test]
329    fn test_apply_input_path_malformed_does_not_panic() {
330        let input = json!({"arr": [1, 2, 3]});
331        // Exercised via the public apply_* entrypoints used by the interpreter.
332        let _ = apply_input_path(&input, Some("$.arr["));
333        let _ = apply_output_path(&input, Some("$.x[é"));
334    }
335}