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.
69fn set_at_path(root: &Value, path: &str, value: &Value) -> Value {
70    let mut result = root.clone();
71    let path = path.strip_prefix("$.").unwrap_or(path);
72    let segments: Vec<&str> = path.split('.').collect();
73
74    let mut current = &mut result;
75    for (i, segment) in segments.iter().enumerate() {
76        if i == segments.len() - 1 {
77            // Last segment — set the value
78            if let Some(obj) = current.as_object_mut() {
79                obj.insert(segment.to_string(), value.clone());
80            }
81        } else {
82            // Intermediate — ensure object exists
83            if current.get(*segment).is_none() {
84                if let Some(obj) = current.as_object_mut() {
85                    obj.insert(segment.to_string(), serde_json::json!({}));
86                }
87            }
88            match current.get_mut(*segment) {
89                Some(v) => current = v,
90                None => return result, // non-object intermediate, bail out
91            }
92        }
93    }
94
95    result
96}
97
98enum PathSegment<'a> {
99    Field(&'a str),
100    Index(&'a str, usize),
101}
102
103fn split_path_segments(path: &str) -> Vec<PathSegment<'_>> {
104    let mut segments = Vec::new();
105    for part in path.split('.') {
106        match parse_index_segment(part) {
107            Some(segment) => segments.push(segment),
108            None => segments.push(PathSegment::Field(part)),
109        }
110    }
111    segments
112}
113
114/// Attempt to parse a `name[idx]` segment. Returns `None` (so the caller treats
115/// the part as a plain field) for any malformed bracket expression. This must
116/// never panic on adversarial input such as `"arr["` (no trailing `]`) or
117/// `"x[é"` (multibyte char where the close bracket would be), both of which are
118/// accepted by `CreateStateMachine` today.
119fn parse_index_segment(part: &str) -> Option<PathSegment<'_>> {
120    let open = part.find('[')?;
121    // Must actually be a closed `[...]` ending the segment.
122    if !part.ends_with(']') {
123        return None;
124    }
125    let close = part.len() - 1;
126    // `close` lands on the `]` byte; the index content is between the brackets.
127    // Guard against `[]` (empty) and ranges that would underflow.
128    let inner_start = open + 1;
129    if inner_start > close {
130        return None;
131    }
132    // Both `inner_start` and `close` are at ASCII bracket boundaries, so slicing
133    // here can never split a multibyte char. The slice content itself may still
134    // be non-ASCII, but it only needs to parse as a usize.
135    let idx_str = part.get(inner_start..close)?;
136    let name = part.get(..open)?;
137    let idx = idx_str.parse::<usize>().ok()?;
138    Some(PathSegment::Index(name, idx))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use serde_json::json;
145
146    #[test]
147    fn test_resolve_path_root() {
148        let input = json!({"a": 1});
149        assert_eq!(resolve_path(&input, "$"), input);
150    }
151
152    #[test]
153    fn test_resolve_path_simple_field() {
154        let input = json!({"name": "hello", "value": 42});
155        assert_eq!(resolve_path(&input, "$.name"), json!("hello"));
156        assert_eq!(resolve_path(&input, "$.value"), json!(42));
157    }
158
159    #[test]
160    fn test_resolve_path_nested() {
161        let input = json!({"a": {"b": {"c": 99}}});
162        assert_eq!(resolve_path(&input, "$.a.b.c"), json!(99));
163    }
164
165    #[test]
166    fn test_resolve_path_missing() {
167        let input = json!({"a": 1});
168        assert_eq!(resolve_path(&input, "$.missing"), Value::Null);
169    }
170
171    #[test]
172    fn test_resolve_path_array_index() {
173        let input = json!({"items": [10, 20, 30]});
174        assert_eq!(resolve_path(&input, "$.items[0]"), json!(10));
175        assert_eq!(resolve_path(&input, "$.items[2]"), json!(30));
176    }
177
178    #[test]
179    fn test_apply_input_path_default() {
180        let input = json!({"x": 1});
181        assert_eq!(apply_input_path(&input, None), input);
182        assert_eq!(apply_input_path(&input, Some("$")), input);
183    }
184
185    #[test]
186    fn test_apply_result_path_default() {
187        let input = json!({"x": 1});
188        let result = json!({"y": 2});
189        // Default: result replaces input
190        assert_eq!(apply_result_path(&input, &result, None), result);
191        assert_eq!(apply_result_path(&input, &result, Some("$")), result);
192    }
193
194    #[test]
195    fn test_apply_result_path_null() {
196        let input = json!({"x": 1});
197        let result = json!({"y": 2});
198        // null: discard result, keep input
199        assert_eq!(apply_result_path(&input, &result, Some("null")), input);
200    }
201
202    #[test]
203    fn test_apply_result_path_nested() {
204        let input = json!({"x": 1});
205        let result = json!("hello");
206        let output = apply_result_path(&input, &result, Some("$.result"));
207        assert_eq!(output, json!({"x": 1, "result": "hello"}));
208    }
209
210    #[test]
211    fn test_set_at_path_non_object_intermediate() {
212        // When an intermediate path segment is a non-object (e.g., a number),
213        // set_at_path should not panic — it should bail out gracefully.
214        let input = json!({"x": 42});
215        let result = json!("hello");
216        let output = apply_result_path(&input, &result, Some("$.x.nested"));
217        // x is a number, can't set nested on it — should return input unchanged
218        assert_eq!(output, json!({"x": 42}));
219    }
220
221    #[test]
222    fn test_apply_output_path() {
223        let output = json!({"a": 1, "b": 2});
224        assert_eq!(apply_output_path(&output, Some("$.a")), json!(1));
225        assert_eq!(apply_output_path(&output, None), output);
226    }
227
228    #[test]
229    fn test_resolve_path_unclosed_bracket_does_not_panic() {
230        // Malformed JSONPath: `[` with no trailing `]`. Previously this sliced
231        // `[bracket_pos + 1 .. part.len() - 1]` which underflows -> panic.
232        let input = json!({"arr": [1, 2, 3]});
233        // No field literally named "arr[" exists, so this resolves to Null,
234        // but the key requirement is that it must NOT panic.
235        assert_eq!(resolve_path(&input, "$.arr["), Value::Null);
236    }
237
238    #[test]
239    fn test_resolve_path_multibyte_after_bracket_does_not_panic() {
240        // Multibyte char where the close bracket would be. `part.len() - 1`
241        // previously landed mid-char -> "byte index is not a char boundary".
242        let input = json!({"x": [1, 2, 3]});
243        assert_eq!(resolve_path(&input, "$.x[é"), Value::Null);
244        // Also the closed-but-multibyte-inner case.
245        assert_eq!(resolve_path(&input, "$.x[é]"), Value::Null);
246    }
247
248    #[test]
249    fn test_resolve_path_empty_brackets_do_not_panic() {
250        let input = json!({"x": [1, 2, 3]});
251        assert_eq!(resolve_path(&input, "$.x[]"), Value::Null);
252    }
253
254    #[test]
255    fn test_resolve_path_bracket_only_segment() {
256        // A bare `[` as an entire segment.
257        let input = json!({"a": 1});
258        assert_eq!(resolve_path(&input, "$.["), Value::Null);
259        assert_eq!(resolve_path(&input, "$.]"), Value::Null);
260    }
261
262    #[test]
263    fn test_split_path_segments_well_formed_index_still_works() {
264        // Ensure the hardening did not break the happy path.
265        let input = json!({"items": [10, 20, 30]});
266        assert_eq!(resolve_path(&input, "$.items[1]"), json!(20));
267        let nested = json!({"a": {"b": [{"c": 7}]}});
268        assert_eq!(resolve_path(&nested, "$.a.b[0].c"), json!(7));
269    }
270
271    #[test]
272    fn test_apply_input_path_malformed_does_not_panic() {
273        let input = json!({"arr": [1, 2, 3]});
274        // Exercised via the public apply_* entrypoints used by the interpreter.
275        let _ = apply_input_path(&input, Some("$.arr["));
276        let _ = apply_output_path(&input, Some("$.x[é"));
277    }
278}