Skip to main content

fakecloud_stepfunctions/
io_processing.rs

1use serde_json::Value;
2
3use crate::jsonpath::{JsonPath, PathError, Step};
4
5/// A Step Functions error: `(error, cause)`.
6pub type StatesError = (String, String);
7
8/// Apply InputPath to extract a subset of the raw input.
9/// - `None` or `Some("$")` -> return input unchanged
10/// - `Some("null")` handled at call site (pass `{}`)
11/// - any other JSONPath -> the selected value; a definite path that matches
12///   nothing fails the state with `States.Runtime`, as on AWS
13pub fn apply_input_path(input: &Value, path: Option<&str>) -> Result<Value, StatesError> {
14    match path {
15        None | Some("$") => Ok(input.clone()),
16        Some(p) => resolve_reference(input, p),
17    }
18}
19
20/// Apply OutputPath to extract a subset of the effective output.
21/// Same semantics as InputPath.
22pub fn apply_output_path(output: &Value, path: Option<&str>) -> Result<Value, StatesError> {
23    match path {
24        None | Some("$") => Ok(output.clone()),
25        Some(p) => resolve_reference(output, p),
26    }
27}
28
29/// Apply ResultPath to merge a state's result into the input.
30/// - `None` or `Some("$")` -> result replaces input entirely
31/// - `Some("null")` -> discard result, return original input
32/// - `Some("$.foo")` -> set result at that path within input
33pub fn apply_result_path(input: &Value, result: &Value, path: Option<&str>) -> Value {
34    match path {
35        None | Some("$") => result.clone(),
36        Some("null") => input.clone(),
37        Some(p) => set_at_path(input, p, result),
38    }
39}
40
41/// Evaluate a JSONPath against `root` (see [`crate::jsonpath`]).
42pub fn resolve_path(root: &Value, path: &str) -> Result<Value, PathError> {
43    crate::jsonpath::evaluate(root, path)
44}
45
46/// Resolve a path, mapping a miss to the `States.Runtime` error AWS raises
47/// for InputPath / OutputPath / ItemsPath / SecondsPath and friends:
48/// `Invalid path '$.x' : No results for path: $['x']`.
49pub fn resolve_reference(root: &Value, path: &str) -> Result<Value, StatesError> {
50    resolve_path(root, path).map_err(|e| runtime_error(path_error_cause(path, &e)))
51}
52
53/// [`resolve_reference`] for a path that may address the context object
54/// (`$$...`), as `ItemsPath`, `SecondsPath` and friends may.
55pub fn resolve_reference_with_context(
56    input: &Value,
57    context: Option<&Value>,
58    path: &str,
59) -> Result<Value, StatesError> {
60    resolve_with_context(input, context, path)
61        .map_err(|e| runtime_error(path_error_cause(path, &e)))
62}
63
64/// Resolve a path that may address the context object (`$$...`). Without a
65/// context object, `$$` paths resolve to null.
66pub fn resolve_with_context(
67    input: &Value,
68    context: Option<&Value>,
69    path: &str,
70) -> Result<Value, PathError> {
71    match path.strip_prefix("$$") {
72        Some(rest) => match context {
73            Some(ctx) => resolve_path(ctx, &format!("${rest}")),
74            None => Ok(Value::Null),
75        },
76        None => resolve_path(input, path),
77    }
78}
79
80pub fn path_error_cause(path: &str, err: &PathError) -> String {
81    match err {
82        PathError::NoResults(norm) => {
83            format!("Invalid path '{path}' : No results for path: {norm}")
84        }
85        PathError::Invalid(msg) => format!("Invalid path '{path}' : {msg}"),
86    }
87}
88
89pub fn runtime_error(cause: String) -> StatesError {
90    ("States.Runtime".to_string(), cause)
91}
92
93/// Set a value at a reference path (`$.a.b`, `$['a'][0]`) within a JSON
94/// structure. An index step descends into the array stored at that point,
95/// growing it with nulls as needed. A path that runs into a scalar leaves the
96/// input unchanged.
97fn set_at_path(root: &Value, path: &str, value: &Value) -> Value {
98    let mut result = root.clone();
99    let Some(steps) = JsonPath::parse(path).ok().and_then(|p| p.reference_steps()) else {
100        return result;
101    };
102
103    // The empty container a missing intermediate node is created as.
104    fn container_for(next: &Step) -> Value {
105        match next {
106            Step::Name(_) => serde_json::json!({}),
107            Step::Index(_) => Value::Array(Vec::new()),
108        }
109    }
110
111    fn assign(current: &mut Value, steps: &[Step], value: &Value) {
112        let Some((step, rest)) = steps.split_first() else {
113            *current = value.clone();
114            return;
115        };
116        match step {
117            Step::Name(name) => {
118                if let Some(obj) = current.as_object_mut() {
119                    match rest.first() {
120                        None => {
121                            obj.insert(name.clone(), value.clone());
122                        }
123                        Some(next) => {
124                            let child = obj
125                                .entry(name.clone())
126                                .or_insert_with(|| container_for(next));
127                            assign(child, rest, value);
128                        }
129                    }
130                }
131            }
132            Step::Index(idx) => {
133                if let Some(arr) = current.as_array_mut() {
134                    let idx = if *idx < 0 {
135                        match usize::try_from(arr.len() as i64 + idx) {
136                            Ok(i) => i,
137                            Err(_) => return,
138                        }
139                    } else {
140                        *idx as usize
141                    };
142                    if arr.len() <= idx {
143                        arr.resize(idx + 1, Value::Null);
144                    }
145                    if let Some(next) = rest.first() {
146                        if arr[idx].is_null() {
147                            arr[idx] = container_for(next);
148                        }
149                    }
150                    assign(&mut arr[idx], rest, value);
151                }
152            }
153        }
154    }
155
156    assign(&mut result, &steps, value);
157    result
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use serde_json::json;
164
165    #[test]
166    fn test_resolve_path_root() {
167        let input = json!({"a": 1});
168        assert_eq!(resolve_path(&input, "$").unwrap(), input);
169    }
170
171    #[test]
172    fn test_resolve_path_simple_field() {
173        let input = json!({"name": "hello", "value": 42});
174        assert_eq!(resolve_path(&input, "$.name").unwrap(), json!("hello"));
175        assert_eq!(resolve_path(&input, "$.value").unwrap(), json!(42));
176    }
177
178    #[test]
179    fn test_resolve_path_nested() {
180        let input = json!({"a": {"b": {"c": 99}}});
181        assert_eq!(resolve_path(&input, "$.a.b.c").unwrap(), json!(99));
182    }
183
184    #[test]
185    fn test_resolve_path_missing() {
186        let input = json!({"a": 1});
187        assert_eq!(
188            resolve_path(&input, "$.missing").unwrap_err(),
189            PathError::NoResults("$['missing']".into())
190        );
191    }
192
193    #[test]
194    fn test_resolve_path_array_index() {
195        let input = json!({"items": [10, 20, 30]});
196        assert_eq!(resolve_path(&input, "$.items[0]").unwrap(), json!(10));
197        assert_eq!(resolve_path(&input, "$.items[2]").unwrap(), json!(30));
198    }
199
200    #[test]
201    fn test_apply_input_path_default() {
202        let input = json!({"x": 1});
203        assert_eq!(apply_input_path(&input, None).unwrap(), input);
204        assert_eq!(apply_input_path(&input, Some("$")).unwrap(), input);
205    }
206
207    #[test]
208    fn test_apply_result_path_default() {
209        let input = json!({"x": 1});
210        let result = json!({"y": 2});
211        // Default: result replaces input
212        assert_eq!(apply_result_path(&input, &result, None), result);
213        assert_eq!(apply_result_path(&input, &result, Some("$")), result);
214    }
215
216    #[test]
217    fn test_apply_result_path_null() {
218        let input = json!({"x": 1});
219        let result = json!({"y": 2});
220        // null: discard result, keep input
221        assert_eq!(apply_result_path(&input, &result, Some("null")), input);
222    }
223
224    #[test]
225    fn test_apply_result_path_nested() {
226        let input = json!({"x": 1});
227        let result = json!("hello");
228        let output = apply_result_path(&input, &result, Some("$.result"));
229        assert_eq!(output, json!({"x": 1, "result": "hello"}));
230    }
231
232    #[test]
233    fn test_set_at_path_non_object_intermediate() {
234        // When an intermediate path segment is a non-object (e.g., a number),
235        // set_at_path should not panic — it should bail out gracefully.
236        let input = json!({"x": 42});
237        let result = json!("hello");
238        let output = apply_result_path(&input, &result, Some("$.x.nested"));
239        // x is a number, can't set nested on it — should return input unchanged
240        assert_eq!(output, json!({"x": 42}));
241    }
242
243    // L8: ResultPath with an array-index segment must descend into the array,
244    // not insert a literal key like "a[0]".
245    #[test]
246    fn test_set_at_path_array_index_leaf() {
247        let input = json!({"a": [1, 2, 3]});
248        let result = json!(99);
249        let output = apply_result_path(&input, &result, Some("$.a[1]"));
250        assert_eq!(output, json!({"a": [1, 99, 3]}));
251        // No stray literal "a[1]" key was created.
252        assert!(output.get("a[1]").is_none());
253    }
254
255    #[test]
256    fn test_set_at_path_array_index_then_field() {
257        let input = json!({"items": [{"v": 1}]});
258        let result = json!("done");
259        let output = apply_result_path(&input, &result, Some("$.items[0].status"));
260        assert_eq!(output, json!({"items": [{"v": 1, "status": "done"}]}));
261    }
262
263    #[test]
264    fn test_set_at_path_array_index_grows_array() {
265        let input = json!({});
266        let result = json!("x");
267        let output = apply_result_path(&input, &result, Some("$.a[2]"));
268        assert_eq!(output, json!({"a": [null, null, "x"]}));
269    }
270
271    #[test]
272    fn test_apply_output_path() {
273        let output = json!({"a": 1, "b": 2});
274        assert_eq!(apply_output_path(&output, Some("$.a")).unwrap(), json!(1));
275        assert_eq!(apply_output_path(&output, None).unwrap(), output);
276    }
277
278    #[test]
279    fn test_resolve_path_unclosed_bracket_does_not_panic() {
280        // Malformed JSONPath: `[` with no trailing `]`. Previously this sliced
281        // `[bracket_pos + 1 .. part.len() - 1]` which underflows -> panic.
282        let input = json!({"arr": [1, 2, 3]});
283        // No field literally named "arr[" exists, so this resolves to Null,
284        // but the key requirement is that it must NOT panic.
285        assert!(matches!(
286            resolve_path(&input, "$.arr["),
287            Err(PathError::Invalid(_))
288        ));
289    }
290
291    #[test]
292    fn test_resolve_path_multibyte_after_bracket_does_not_panic() {
293        // Multibyte char where the close bracket would be. `part.len() - 1`
294        // previously landed mid-char -> "byte index is not a char boundary".
295        let input = json!({"x": [1, 2, 3]});
296        assert!(matches!(
297            resolve_path(&input, "$.x[é"),
298            Err(PathError::Invalid(_))
299        ));
300        // Also the closed-but-multibyte-inner case.
301        assert!(matches!(
302            resolve_path(&input, "$.x[é]"),
303            Err(PathError::Invalid(_))
304        ));
305    }
306
307    #[test]
308    fn test_resolve_path_empty_brackets_do_not_panic() {
309        let input = json!({"x": [1, 2, 3]});
310        assert!(matches!(
311            resolve_path(&input, "$.x[]"),
312            Err(PathError::Invalid(_))
313        ));
314    }
315
316    #[test]
317    fn test_resolve_path_bracket_only_segment() {
318        // A bare `[` as an entire segment.
319        let input = json!({"a": 1});
320        assert!(matches!(
321            resolve_path(&input, "$.["),
322            Err(PathError::Invalid(_))
323        ));
324        assert!(matches!(
325            resolve_path(&input, "$.]"),
326            Err(PathError::Invalid(_))
327        ));
328    }
329
330    #[test]
331    fn test_split_path_segments_well_formed_index_still_works() {
332        // Ensure the hardening did not break the happy path.
333        let input = json!({"items": [10, 20, 30]});
334        assert_eq!(resolve_path(&input, "$.items[1]").unwrap(), json!(20));
335        let nested = json!({"a": {"b": [{"c": 7}]}});
336        assert_eq!(resolve_path(&nested, "$.a.b[0].c").unwrap(), json!(7));
337    }
338
339    #[test]
340    fn test_apply_input_path_malformed_does_not_panic() {
341        let input = json!({"arr": [1, 2, 3]});
342        // Exercised via the public apply_* entrypoints used by the interpreter.
343        let (err, _) = apply_input_path(&input, Some("$.arr[")).unwrap_err();
344        assert_eq!(err, "States.Runtime");
345        assert!(apply_output_path(&input, Some("$.x[é")).is_err());
346    }
347}
348
349#[cfg(test)]
350mod missing_path_tests {
351    use super::*;
352    use serde_json::json;
353
354    #[test]
355    fn missing_input_path_is_states_runtime() {
356        let (err, cause) = apply_input_path(&json!({"a": 1}), Some("$.x")).unwrap_err();
357        assert_eq!(err, "States.Runtime");
358        assert_eq!(cause, "Invalid path '$.x' : No results for path: $['x']");
359        let (err, _) = apply_output_path(&json!({"a": 1}), Some("$.a.b")).unwrap_err();
360        assert_eq!(err, "States.Runtime");
361        // A present null is fine.
362        assert_eq!(
363            apply_input_path(&json!({"a": null}), Some("$.a")).unwrap(),
364            Value::Null
365        );
366    }
367
368    #[test]
369    fn full_jsonpath_syntax_in_input_path() {
370        let input =
371            json!({"items": [{"id": 1, "ok": true}, {"id": 2, "ok": false}], "k": {"x y": 5}});
372        assert_eq!(
373            apply_input_path(&input, Some("$['k']['x y']")).unwrap(),
374            json!(5)
375        );
376        assert_eq!(
377            apply_input_path(&input, Some("$.items[-1].id")).unwrap(),
378            json!(2)
379        );
380        assert_eq!(
381            apply_input_path(&input, Some("$.items[*].id")).unwrap(),
382            json!([1, 2])
383        );
384        assert_eq!(
385            apply_input_path(&input, Some("$.items[?(@.ok == true)].id")).unwrap(),
386            json!([1])
387        );
388        assert_eq!(
389            apply_input_path(&input, Some("$.items[0:1]")).unwrap(),
390            json!([{"id": 1, "ok": true}])
391        );
392    }
393
394    #[test]
395    fn result_path_bracket_notation() {
396        let out = apply_result_path(&json!({"a": {}}), &json!(1), Some("$['a']['b c']"));
397        assert_eq!(out, json!({"a": {"b c": 1}}));
398        let out = apply_result_path(&json!({"a": [1, 2]}), &json!(9), Some("$.a[-1]"));
399        assert_eq!(out, json!({"a": [1, 9]}));
400    }
401
402    #[test]
403    fn context_paths() {
404        let ctx = json!({"Execution": {"Id": "arn"}});
405        assert_eq!(
406            resolve_with_context(&json!({}), Some(&ctx), "$$.Execution.Id").unwrap(),
407            json!("arn")
408        );
409        assert!(resolve_with_context(&json!({}), Some(&ctx), "$$.Nope").is_err());
410        assert_eq!(
411            resolve_with_context(&json!({"a": 1}), None, "$.a").unwrap(),
412            json!(1)
413        );
414    }
415}