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
33///
34/// A path that walks into a value that can't hold its next step (`$.a.b` with
35/// `a: 1`) fails with `States.ReferencePathConflict`, as recorded.
36pub fn apply_result_path(
37    input: &Value,
38    result: &Value,
39    path: Option<&str>,
40) -> Result<Value, StatesError> {
41    match path {
42        None | Some("$") => Ok(result.clone()),
43        Some("null") => Ok(input.clone()),
44        Some(p) => set_at_path(input, p, result),
45    }
46}
47
48/// The error a `ResultPath` that can't be applied fails with.
49pub const REFERENCE_PATH_CONFLICT: &str = "States.ReferencePathConflict";
50
51/// As recorded: `Unable to apply step "b" to input {"a":1}`, naming the step and
52/// the whole input.
53fn reference_path_conflict(step: &Step, input: &Value) -> StatesError {
54    let step = match step {
55        Step::Name(name) => name.clone(),
56        Step::Index(index) => index.to_string(),
57    };
58    (
59        REFERENCE_PATH_CONFLICT.to_string(),
60        format!("Unable to apply step \"{step}\" to input {input}"),
61    )
62}
63
64/// Evaluate a JSONPath against `root` (see [`crate::jsonpath`]).
65pub fn resolve_path(root: &Value, path: &str) -> Result<Value, PathError> {
66    crate::jsonpath::evaluate(root, path)
67}
68
69/// Resolve a path, mapping a miss to the `States.Runtime` error AWS raises
70/// for InputPath / OutputPath / ItemsPath / SecondsPath and friends:
71/// `Invalid path '$.x' : No results for path: $['x']`.
72pub fn resolve_reference(root: &Value, path: &str) -> Result<Value, StatesError> {
73    resolve_with_context(root, None, path).map_err(|e| runtime_error(path_error_cause(path, &e)))
74}
75
76/// [`resolve_reference`] for a path that may address the context object
77/// (`$$...`), as `ItemsPath`, `SecondsPath` and friends may.
78pub fn resolve_reference_with_context(
79    input: &Value,
80    context: Option<&Value>,
81    path: &str,
82) -> Result<Value, StatesError> {
83    resolve_with_context(input, context, path)
84        .map_err(|e| runtime_error(path_error_cause(path, &e)))
85}
86
87/// Resolve a path that may address the context object (`$$...`). Without a
88/// context object, `$$` paths resolve to null.
89pub fn resolve_with_context(
90    input: &Value,
91    context: Option<&Value>,
92    path: &str,
93) -> Result<Value, PathError> {
94    if let Some((name, within)) = crate::variables::split_reference(path) {
95        let value = crate::variables::lookup(name)
96            .ok_or_else(|| PathError::Invalid(format!("the variable '{name}' is not defined")))?;
97        return resolve_path(&value, &within);
98    }
99    match path.strip_prefix("$$") {
100        Some(rest) => match context {
101            Some(ctx) => resolve_path(ctx, &format!("${rest}")),
102            None => Ok(Value::Null),
103        },
104        None => resolve_path(input, path),
105    }
106}
107
108pub fn path_error_cause(path: &str, err: &PathError) -> String {
109    match err {
110        PathError::NoResults(norm) => {
111            format!("Invalid path '{path}' : No results for path: {norm}")
112        }
113        // As recorded for `$.missing` on an array: `Invalid path '$.missing' :
114        // Property ['missing'] not found in path $`.
115        PathError::MissingProperty { property, parent } => {
116            format!("Invalid path '{path}' : Property {property} not found in path {parent}")
117        }
118        PathError::Invalid(msg) => format!("Invalid path '{path}' : {msg}"),
119    }
120}
121
122/// A value as error messages quote it: a string's text, anything else as JSON.
123pub(crate) fn value_text(value: &Value) -> String {
124    match value {
125        Value::String(text) => text.clone(),
126        other => other.to_string(),
127    }
128}
129
130pub fn runtime_error(cause: String) -> StatesError {
131    ("States.Runtime".to_string(), cause)
132}
133
134/// Set a value at a reference path (`$.a.b`, `$['a'][0]`) within a JSON
135/// structure. An index step descends into the array stored at that point,
136/// growing it with nulls as needed. A step into a value that can't hold it (a
137/// property of a non-object, an index of a non-array) is a conflict.
138fn set_at_path(root: &Value, path: &str, value: &Value) -> Result<Value, StatesError> {
139    let mut result = root.clone();
140    let Some(steps) = JsonPath::parse(path).ok().and_then(|p| p.reference_steps()) else {
141        return Ok(result);
142    };
143
144    // The empty container a missing intermediate node is created as.
145    fn container_for(next: &Step) -> Value {
146        match next {
147            Step::Name(_) => serde_json::json!({}),
148            Step::Index(_) => Value::Array(Vec::new()),
149        }
150    }
151
152    /// Assign `value` at `steps` below `current`, or return the step that
153    /// can't be taken.
154    fn assign<'s>(current: &mut Value, steps: &'s [Step], value: &Value) -> Result<(), &'s Step> {
155        let Some((step, rest)) = steps.split_first() else {
156            *current = value.clone();
157            return Ok(());
158        };
159        match step {
160            Step::Name(name) => {
161                let Some(obj) = current.as_object_mut() else {
162                    return Err(step);
163                };
164                match rest.first() {
165                    None => {
166                        obj.insert(name.clone(), value.clone());
167                        Ok(())
168                    }
169                    Some(next) => {
170                        let child = obj
171                            .entry(name.clone())
172                            .or_insert_with(|| container_for(next));
173                        assign(child, rest, value)
174                    }
175                }
176            }
177            Step::Index(idx) => {
178                let Some(arr) = current.as_array_mut() else {
179                    return Err(step);
180                };
181                {
182                    let idx = if *idx < 0 {
183                        match usize::try_from(arr.len() as i64 + idx) {
184                            Ok(i) => i,
185                            Err(_) => return Err(step),
186                        }
187                    } else {
188                        *idx as usize
189                    };
190                    if arr.len() <= idx {
191                        arr.resize(idx + 1, Value::Null);
192                    }
193                    if let Some(next) = rest.first() {
194                        if arr[idx].is_null() {
195                            arr[idx] = container_for(next);
196                        }
197                    }
198                    assign(&mut arr[idx], rest, value)
199                }
200            }
201        }
202    }
203
204    assign(&mut result, &steps, value).map_err(|step| reference_path_conflict(step, root))?;
205    Ok(result)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use serde_json::json;
212
213    #[test]
214    fn test_resolve_path_root() {
215        let input = json!({"a": 1});
216        assert_eq!(resolve_path(&input, "$").unwrap(), input);
217    }
218
219    #[test]
220    fn test_resolve_path_simple_field() {
221        let input = json!({"name": "hello", "value": 42});
222        assert_eq!(resolve_path(&input, "$.name").unwrap(), json!("hello"));
223        assert_eq!(resolve_path(&input, "$.value").unwrap(), json!(42));
224    }
225
226    #[test]
227    fn test_resolve_path_nested() {
228        let input = json!({"a": {"b": {"c": 99}}});
229        assert_eq!(resolve_path(&input, "$.a.b.c").unwrap(), json!(99));
230    }
231
232    #[test]
233    fn test_resolve_path_missing() {
234        let input = json!({"a": 1});
235        // Both recorded.
236        let error = resolve_path(&input, "$.missing").unwrap_err();
237        assert_eq!(
238            path_error_cause("$.missing", &error),
239            "Invalid path '$.missing' : No results for path: $['missing']"
240        );
241        let error = resolve_path(&json!([{}]), "$.missing").unwrap_err();
242        assert_eq!(
243            path_error_cause("$.missing", &error),
244            "Invalid path '$.missing' : Property ['missing'] not found in path $"
245        );
246    }
247
248    #[test]
249    fn test_resolve_path_array_index() {
250        let input = json!({"items": [10, 20, 30]});
251        assert_eq!(resolve_path(&input, "$.items[0]").unwrap(), json!(10));
252        assert_eq!(resolve_path(&input, "$.items[2]").unwrap(), json!(30));
253    }
254
255    #[test]
256    fn test_apply_input_path_default() {
257        let input = json!({"x": 1});
258        assert_eq!(apply_input_path(&input, None).unwrap(), input);
259        assert_eq!(apply_input_path(&input, Some("$")).unwrap(), input);
260    }
261
262    #[test]
263    fn test_apply_result_path_default() {
264        let input = json!({"x": 1});
265        let result = json!({"y": 2});
266        // Default: result replaces input
267        assert_eq!(apply_result_path(&input, &result, None).unwrap(), result);
268        assert_eq!(
269            apply_result_path(&input, &result, Some("$")).unwrap(),
270            result
271        );
272    }
273
274    #[test]
275    fn test_apply_result_path_null() {
276        let input = json!({"x": 1});
277        let result = json!({"y": 2});
278        // null: discard result, keep input
279        assert_eq!(
280            apply_result_path(&input, &result, Some("null")).unwrap(),
281            input
282        );
283    }
284
285    #[test]
286    fn test_apply_result_path_nested() {
287        let input = json!({"x": 1});
288        let result = json!("hello");
289        let output = apply_result_path(&input, &result, Some("$.result")).unwrap();
290        assert_eq!(output, json!({"x": 1, "result": "hello"}));
291    }
292
293    #[test]
294    fn test_set_at_path_non_object_intermediate() {
295        // An intermediate non-object can't take the next step: a conflict, as
296        // recorded, never a panic.
297        let input = json!({"x": 42});
298        let result = json!("hello");
299        let (error, cause) = apply_result_path(&input, &result, Some("$.x.nested")).unwrap_err();
300        assert_eq!(error, REFERENCE_PATH_CONFLICT);
301        assert_eq!(cause, r#"Unable to apply step "nested" to input {"x":42}"#);
302    }
303
304    // L8: ResultPath with an array-index segment must descend into the array,
305    // not insert a literal key like "a[0]".
306    #[test]
307    fn test_set_at_path_array_index_leaf() {
308        let input = json!({"a": [1, 2, 3]});
309        let result = json!(99);
310        let output = apply_result_path(&input, &result, Some("$.a[1]")).unwrap();
311        assert_eq!(output, json!({"a": [1, 99, 3]}));
312        // No stray literal "a[1]" key was created.
313        assert!(output.get("a[1]").is_none());
314    }
315
316    #[test]
317    fn test_set_at_path_array_index_then_field() {
318        let input = json!({"items": [{"v": 1}]});
319        let result = json!("done");
320        let output = apply_result_path(&input, &result, Some("$.items[0].status")).unwrap();
321        assert_eq!(output, json!({"items": [{"v": 1, "status": "done"}]}));
322    }
323
324    #[test]
325    fn test_set_at_path_array_index_grows_array() {
326        let input = json!({});
327        let result = json!("x");
328        let output = apply_result_path(&input, &result, Some("$.a[2]")).unwrap();
329        assert_eq!(output, json!({"a": [null, null, "x"]}));
330    }
331
332    #[test]
333    fn test_apply_output_path() {
334        let output = json!({"a": 1, "b": 2});
335        assert_eq!(apply_output_path(&output, Some("$.a")).unwrap(), json!(1));
336        assert_eq!(apply_output_path(&output, None).unwrap(), output);
337    }
338
339    #[test]
340    fn test_resolve_path_unclosed_bracket_does_not_panic() {
341        // Malformed JSONPath: `[` with no trailing `]`. Previously this sliced
342        // `[bracket_pos + 1 .. part.len() - 1]` which underflows -> panic.
343        let input = json!({"arr": [1, 2, 3]});
344        // No field literally named "arr[" exists, so this resolves to Null,
345        // but the key requirement is that it must NOT panic.
346        assert!(matches!(
347            resolve_path(&input, "$.arr["),
348            Err(PathError::Invalid(_))
349        ));
350    }
351
352    #[test]
353    fn test_resolve_path_multibyte_after_bracket_does_not_panic() {
354        // Multibyte char where the close bracket would be. `part.len() - 1`
355        // previously landed mid-char -> "byte index is not a char boundary".
356        let input = json!({"x": [1, 2, 3]});
357        assert!(matches!(
358            resolve_path(&input, "$.x[é"),
359            Err(PathError::Invalid(_))
360        ));
361        // Also the closed-but-multibyte-inner case.
362        assert!(matches!(
363            resolve_path(&input, "$.x[é]"),
364            Err(PathError::Invalid(_))
365        ));
366    }
367
368    #[test]
369    fn test_resolve_path_empty_brackets_do_not_panic() {
370        let input = json!({"x": [1, 2, 3]});
371        assert!(matches!(
372            resolve_path(&input, "$.x[]"),
373            Err(PathError::Invalid(_))
374        ));
375    }
376
377    #[test]
378    fn test_resolve_path_bracket_only_segment() {
379        // A bare `[` as an entire segment.
380        let input = json!({"a": 1});
381        assert!(matches!(
382            resolve_path(&input, "$.["),
383            Err(PathError::Invalid(_))
384        ));
385        assert!(matches!(
386            resolve_path(&input, "$.]"),
387            Err(PathError::Invalid(_))
388        ));
389    }
390
391    #[test]
392    fn test_split_path_segments_well_formed_index_still_works() {
393        // Ensure the hardening did not break the happy path.
394        let input = json!({"items": [10, 20, 30]});
395        assert_eq!(resolve_path(&input, "$.items[1]").unwrap(), json!(20));
396        let nested = json!({"a": {"b": [{"c": 7}]}});
397        assert_eq!(resolve_path(&nested, "$.a.b[0].c").unwrap(), json!(7));
398    }
399
400    #[test]
401    fn test_apply_input_path_malformed_does_not_panic() {
402        let input = json!({"arr": [1, 2, 3]});
403        // Exercised via the public apply_* entrypoints used by the interpreter.
404        let (err, _) = apply_input_path(&input, Some("$.arr[")).unwrap_err();
405        assert_eq!(err, "States.Runtime");
406        assert!(apply_output_path(&input, Some("$.x[é")).is_err());
407    }
408}
409
410#[cfg(test)]
411mod missing_path_tests {
412    use super::*;
413    use serde_json::json;
414
415    #[test]
416    fn missing_input_path_is_states_runtime() {
417        let (err, cause) = apply_input_path(&json!({"a": 1}), Some("$.x")).unwrap_err();
418        assert_eq!(err, "States.Runtime");
419        assert_eq!(cause, "Invalid path '$.x' : No results for path: $['x']");
420        let (err, _) = apply_output_path(&json!({"a": 1}), Some("$.a.b")).unwrap_err();
421        assert_eq!(err, "States.Runtime");
422        // A present null is fine.
423        assert_eq!(
424            apply_input_path(&json!({"a": null}), Some("$.a")).unwrap(),
425            Value::Null
426        );
427    }
428
429    #[test]
430    fn full_jsonpath_syntax_in_input_path() {
431        let input =
432            json!({"items": [{"id": 1, "ok": true}, {"id": 2, "ok": false}], "k": {"x y": 5}});
433        assert_eq!(
434            apply_input_path(&input, Some("$['k']['x y']")).unwrap(),
435            json!(5)
436        );
437        assert_eq!(
438            apply_input_path(&input, Some("$.items[-1].id")).unwrap(),
439            json!(2)
440        );
441        assert_eq!(
442            apply_input_path(&input, Some("$.items[*].id")).unwrap(),
443            json!([1, 2])
444        );
445        assert_eq!(
446            apply_input_path(&input, Some("$.items[?(@.ok == true)].id")).unwrap(),
447            json!([1])
448        );
449        assert_eq!(
450            apply_input_path(&input, Some("$.items[0:1]")).unwrap(),
451            json!([{"id": 1, "ok": true}])
452        );
453    }
454
455    #[test]
456    fn result_path_bracket_notation() {
457        let out = apply_result_path(&json!({"a": {}}), &json!(1), Some("$['a']['b c']")).unwrap();
458        assert_eq!(out, json!({"a": {"b c": 1}}));
459        let out = apply_result_path(&json!({"a": [1, 2]}), &json!(9), Some("$.a[-1]")).unwrap();
460        assert_eq!(out, json!({"a": [1, 9]}));
461    }
462
463    #[test]
464    fn context_paths() {
465        let ctx = json!({"Execution": {"Id": "arn"}});
466        assert_eq!(
467            resolve_with_context(&json!({}), Some(&ctx), "$$.Execution.Id").unwrap(),
468            json!("arn")
469        );
470        assert!(resolve_with_context(&json!({}), Some(&ctx), "$$.Nope").is_err());
471        assert_eq!(
472            resolve_with_context(&json!({"a": 1}), None, "$.a").unwrap(),
473            json!(1)
474        );
475    }
476}
477
478#[cfg(test)]
479mod reference_path_conflict_tests {
480    use super::*;
481    use serde_json::json;
482
483    /// Recorded: `ResultPath: $.a.b` over `{"a":1}`.
484    #[test]
485    fn a_step_into_a_scalar_is_a_conflict() {
486        assert_eq!(
487            apply_result_path(&json!({"a": 1}), &json!([{}]), Some("$.a.b")).unwrap_err(),
488            (
489                "States.ReferencePathConflict".to_string(),
490                r#"Unable to apply step "b" to input {"a":1}"#.to_string()
491            )
492        );
493        // Inferred: an index into a non-array, and a property of a scalar input.
494        assert_eq!(
495            apply_result_path(&json!({"a": {}}), &json!(1), Some("$.a[0]"))
496                .unwrap_err()
497                .0,
498            REFERENCE_PATH_CONFLICT
499        );
500        assert!(apply_result_path(&json!(5), &json!(1), Some("$.x")).is_err());
501        // Missing nodes are still created.
502        assert_eq!(
503            apply_result_path(&json!({}), &json!(1), Some("$.a.b")).unwrap(),
504            json!({"a": {"b": 1}})
505        );
506    }
507}