Skip to main content

fakecloud_stepfunctions/
intrinsics.rs

1//! Amazon States Language intrinsic functions.
2//!
3//! These appear in Parameters / ResultSelector / Arguments / Output
4//! values when the JSON key uses the `.$` suffix and the value is a
5//! string starting with `States.`. This module parses the call,
6//! resolves arguments (JSONPath references vs JSON literals), and
7//! returns the computed value.
8//!
9//! Reference: https://docs.aws.amazon.com/step-functions/latest/dg/intrinsic-functions.html
10
11use base64::Engine;
12use serde_json::{json, Value};
13
14use crate::io_processing::resolve_path;
15
16#[derive(Debug, Clone)]
17pub struct IntrinsicError(pub String);
18
19impl std::fmt::Display for IntrinsicError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        write!(f, "States.IntrinsicFailure: {}", self.0)
22    }
23}
24
25/// Returns true if `value` is a string that should be evaluated as an
26/// intrinsic (`States.Foo(...)`) rather than a JSONPath reference.
27pub fn is_intrinsic_call(value: &str) -> bool {
28    value.starts_with("States.") && value.contains('(')
29}
30
31/// Evaluate an ASL intrinsic call against `input`. Returns the
32/// computed value or an error string suitable for surfacing as
33/// `States.IntrinsicFailure`.
34pub fn evaluate(call: &str, input: &Value) -> Result<Value, IntrinsicError> {
35    let (name, args_str) = split_call(call)?;
36    let args = parse_args(args_str, input)?;
37    match name {
38        "States.Format" => fn_format(&args),
39        "States.JsonToString" => fn_json_to_string(&args),
40        "States.StringToJson" => fn_string_to_json(&args),
41        "States.Array" => Ok(Value::Array(args)),
42        "States.ArrayPartition" => fn_array_partition(&args),
43        "States.ArrayContains" => fn_array_contains(&args),
44        "States.ArrayRange" => fn_array_range(&args),
45        "States.ArrayGetItem" => fn_array_get_item(&args),
46        "States.ArrayLength" => fn_array_length(&args),
47        "States.ArrayUnique" => fn_array_unique(&args),
48        "States.Base64Encode" => fn_base64_encode(&args),
49        "States.Base64Decode" => fn_base64_decode(&args),
50        "States.Hash" => fn_hash(&args),
51        "States.JsonMerge" => fn_json_merge(&args),
52        "States.MathRandom" => fn_math_random(&args),
53        "States.MathAdd" => fn_math_add(&args),
54        "States.UUID" => fn_uuid(&args),
55        "States.StringSplit" => fn_string_split(&args),
56        other => Err(IntrinsicError(format!("unknown intrinsic '{other}'"))),
57    }
58}
59
60fn split_call(call: &str) -> Result<(&str, &str), IntrinsicError> {
61    let open = call
62        .find('(')
63        .ok_or_else(|| IntrinsicError(format!("missing '(' in '{call}'")))?;
64    if !call.ends_with(')') {
65        return Err(IntrinsicError(format!("missing ')' in '{call}'")));
66    }
67    let name = &call[..open];
68    let args_str = &call[open + 1..call.len() - 1];
69    Ok((name, args_str))
70}
71
72fn parse_args(args_str: &str, input: &Value) -> Result<Vec<Value>, IntrinsicError> {
73    let mut out = Vec::new();
74    if args_str.trim().is_empty() {
75        return Ok(out);
76    }
77    for raw in split_top_level_commas(args_str) {
78        let arg = raw.trim();
79        if arg.is_empty() {
80            continue;
81        }
82        out.push(parse_arg(arg, input)?);
83    }
84    Ok(out)
85}
86
87/// Split a comma-separated argument list, ignoring commas that fall
88/// inside quoted strings (`'...'` or `"..."`). Backslash escapes
89/// inside single-quoted strings are honoured.
90fn split_top_level_commas(s: &str) -> Vec<String> {
91    let mut out = Vec::new();
92    let mut current = String::new();
93    let mut in_single = false;
94    let mut in_double = false;
95    let mut chars = s.chars().peekable();
96    while let Some(c) = chars.next() {
97        match c {
98            '\\' if in_single => {
99                if let Some(&next) = chars.peek() {
100                    current.push('\\');
101                    current.push(next);
102                    chars.next();
103                }
104            }
105            '\'' if !in_double => {
106                in_single = !in_single;
107                current.push(c);
108            }
109            '"' if !in_single => {
110                in_double = !in_double;
111                current.push(c);
112            }
113            ',' if !in_single && !in_double => {
114                out.push(current.clone());
115                current.clear();
116            }
117            _ => current.push(c),
118        }
119    }
120    if !current.is_empty() || s.ends_with(',') {
121        out.push(current);
122    }
123    out
124}
125
126fn parse_arg(arg: &str, input: &Value) -> Result<Value, IntrinsicError> {
127    if arg.starts_with('$') {
128        Ok(resolve_path(input, arg))
129    } else if arg.starts_with('\'') && arg.ends_with('\'') && arg.len() >= 2 {
130        // Single-quoted string literal with backslash escapes.
131        let inner = &arg[1..arg.len() - 1];
132        Ok(Value::String(unescape_single_quoted(inner)))
133    } else {
134        // Try JSON literal (number, bool, null, double-quoted string,
135        // object/array). Fall back to bare string.
136        serde_json::from_str(arg)
137            .map_err(|e| IntrinsicError(format!("invalid argument '{arg}': {e}")))
138    }
139}
140
141fn unescape_single_quoted(s: &str) -> String {
142    let mut out = String::with_capacity(s.len());
143    let mut chars = s.chars().peekable();
144    while let Some(c) = chars.next() {
145        if c == '\\' {
146            match chars.next() {
147                Some('\\') => out.push('\\'),
148                Some('\'') => out.push('\''),
149                Some('n') => out.push('\n'),
150                Some('t') => out.push('\t'),
151                Some('{') => out.push('{'),
152                Some('}') => out.push('}'),
153                Some(other) => {
154                    out.push('\\');
155                    out.push(other);
156                }
157                None => out.push('\\'),
158            }
159        } else {
160            out.push(c);
161        }
162    }
163    out
164}
165
166fn arg_as_str(v: &Value) -> Result<String, IntrinsicError> {
167    match v {
168        Value::String(s) => Ok(s.clone()),
169        other => Ok(serde_json::to_string(other).unwrap_or_default()),
170    }
171}
172
173fn arg_as_array(v: &Value) -> Result<&Vec<Value>, IntrinsicError> {
174    v.as_array()
175        .ok_or_else(|| IntrinsicError(format!("expected array, got {v}")))
176}
177
178fn arg_as_i64(v: &Value) -> Result<i64, IntrinsicError> {
179    v.as_i64()
180        .or_else(|| v.as_f64().map(|f| f as i64))
181        .ok_or_else(|| IntrinsicError(format!("expected integer, got {v}")))
182}
183
184fn need_args(args: &[Value], expected: usize, name: &str) -> Result<(), IntrinsicError> {
185    if args.len() != expected {
186        Err(IntrinsicError(format!(
187            "{name} expected {expected} args, got {}",
188            args.len()
189        )))
190    } else {
191        Ok(())
192    }
193}
194
195fn fn_format(args: &[Value]) -> Result<Value, IntrinsicError> {
196    if args.is_empty() {
197        return Err(IntrinsicError(
198            "States.Format requires at least one argument".into(),
199        ));
200    }
201    let template = args[0]
202        .as_str()
203        .ok_or_else(|| IntrinsicError("States.Format template must be a string".into()))?;
204    let mut out = String::with_capacity(template.len());
205    let mut chars = template.chars().peekable();
206    let mut idx = 1;
207    while let Some(c) = chars.next() {
208        match c {
209            '\\' => {
210                if let Some(&n) = chars.peek() {
211                    out.push(n);
212                    chars.next();
213                }
214            }
215            '{' if matches!(chars.peek(), Some('}')) => {
216                chars.next();
217                let v = args.get(idx).ok_or_else(|| {
218                    IntrinsicError("States.Format placeholder count exceeds args".into())
219                })?;
220                idx += 1;
221                match v {
222                    Value::String(s) => out.push_str(s),
223                    Value::Null => out.push_str("null"),
224                    other => out.push_str(&serde_json::to_string(other).unwrap_or_default()),
225                }
226            }
227            _ => out.push(c),
228        }
229    }
230    Ok(Value::String(out))
231}
232
233fn fn_json_to_string(args: &[Value]) -> Result<Value, IntrinsicError> {
234    need_args(args, 1, "States.JsonToString")?;
235    Ok(Value::String(
236        serde_json::to_string(&args[0]).unwrap_or_default(),
237    ))
238}
239
240fn fn_string_to_json(args: &[Value]) -> Result<Value, IntrinsicError> {
241    need_args(args, 1, "States.StringToJson")?;
242    let s = args[0]
243        .as_str()
244        .ok_or_else(|| IntrinsicError("States.StringToJson arg must be a string".into()))?;
245    serde_json::from_str(s)
246        .map_err(|e| IntrinsicError(format!("States.StringToJson parse failed: {e}")))
247}
248
249fn fn_array_partition(args: &[Value]) -> Result<Value, IntrinsicError> {
250    need_args(args, 2, "States.ArrayPartition")?;
251    let arr = arg_as_array(&args[0])?;
252    let chunk = arg_as_i64(&args[1])?;
253    if chunk <= 0 {
254        return Err(IntrinsicError(
255            "ArrayPartition chunk size must be > 0".into(),
256        ));
257    }
258    let chunk = chunk as usize;
259    let mut out: Vec<Value> = Vec::new();
260    for slice in arr.chunks(chunk) {
261        out.push(Value::Array(slice.to_vec()));
262    }
263    Ok(Value::Array(out))
264}
265
266fn fn_array_contains(args: &[Value]) -> Result<Value, IntrinsicError> {
267    need_args(args, 2, "States.ArrayContains")?;
268    let arr = arg_as_array(&args[0])?;
269    Ok(Value::Bool(arr.iter().any(|v| v == &args[1])))
270}
271
272fn fn_array_range(args: &[Value]) -> Result<Value, IntrinsicError> {
273    need_args(args, 3, "States.ArrayRange")?;
274    let start = arg_as_i64(&args[0])?;
275    let end = arg_as_i64(&args[1])?;
276    let step = arg_as_i64(&args[2])?;
277    if step == 0 {
278        return Err(IntrinsicError("ArrayRange step must be != 0".into()));
279    }
280    let mut out = Vec::new();
281    let mut i = start;
282    if step > 0 {
283        while i <= end {
284            out.push(json!(i));
285            i += step;
286        }
287    } else {
288        while i >= end {
289            out.push(json!(i));
290            i += step;
291        }
292    }
293    Ok(Value::Array(out))
294}
295
296fn fn_array_get_item(args: &[Value]) -> Result<Value, IntrinsicError> {
297    need_args(args, 2, "States.ArrayGetItem")?;
298    let arr = arg_as_array(&args[0])?;
299    let idx = arg_as_i64(&args[1])?;
300    if idx < 0 {
301        return Err(IntrinsicError("ArrayGetItem index must be >= 0".into()));
302    }
303    Ok(arr.get(idx as usize).cloned().unwrap_or(Value::Null))
304}
305
306fn fn_array_length(args: &[Value]) -> Result<Value, IntrinsicError> {
307    need_args(args, 1, "States.ArrayLength")?;
308    let arr = arg_as_array(&args[0])?;
309    Ok(json!(arr.len()))
310}
311
312fn fn_array_unique(args: &[Value]) -> Result<Value, IntrinsicError> {
313    need_args(args, 1, "States.ArrayUnique")?;
314    let arr = arg_as_array(&args[0])?;
315    let mut seen: Vec<Value> = Vec::new();
316    for v in arr {
317        if !seen.contains(v) {
318            seen.push(v.clone());
319        }
320    }
321    Ok(Value::Array(seen))
322}
323
324fn fn_base64_encode(args: &[Value]) -> Result<Value, IntrinsicError> {
325    need_args(args, 1, "States.Base64Encode")?;
326    let s = arg_as_str(&args[0])?;
327    Ok(Value::String(
328        base64::engine::general_purpose::STANDARD.encode(s.as_bytes()),
329    ))
330}
331
332fn fn_base64_decode(args: &[Value]) -> Result<Value, IntrinsicError> {
333    need_args(args, 1, "States.Base64Decode")?;
334    let s = arg_as_str(&args[0])?;
335    let bytes = base64::engine::general_purpose::STANDARD
336        .decode(s.as_bytes())
337        .map_err(|e| IntrinsicError(format!("Base64Decode failed: {e}")))?;
338    let decoded = String::from_utf8(bytes)
339        .map_err(|e| IntrinsicError(format!("Base64Decode utf8 failed: {e}")))?;
340    Ok(Value::String(decoded))
341}
342
343fn fn_hash(args: &[Value]) -> Result<Value, IntrinsicError> {
344    use md5::Digest;
345    need_args(args, 2, "States.Hash")?;
346    let input = arg_as_str(&args[0])?;
347    let algo = arg_as_str(&args[1])?;
348    let digest_hex = match algo.as_str() {
349        "MD5" => {
350            let mut h = md5::Md5::new();
351            h.update(input.as_bytes());
352            hex::encode(h.finalize())
353        }
354        "SHA-1" => {
355            let mut h = sha1::Sha1::new();
356            h.update(input.as_bytes());
357            hex::encode(h.finalize())
358        }
359        "SHA-256" => {
360            let mut h = sha2::Sha256::new();
361            h.update(input.as_bytes());
362            hex::encode(h.finalize())
363        }
364        "SHA-384" => {
365            let mut h = sha2::Sha384::new();
366            h.update(input.as_bytes());
367            hex::encode(h.finalize())
368        }
369        "SHA-512" => {
370            let mut h = sha2::Sha512::new();
371            h.update(input.as_bytes());
372            hex::encode(h.finalize())
373        }
374        other => {
375            return Err(IntrinsicError(format!(
376                "unsupported hash algorithm '{other}'"
377            )))
378        }
379    };
380    Ok(Value::String(digest_hex))
381}
382
383fn fn_json_merge(args: &[Value]) -> Result<Value, IntrinsicError> {
384    need_args(args, 3, "States.JsonMerge")?;
385    let a = args[0]
386        .as_object()
387        .ok_or_else(|| IntrinsicError("JsonMerge arg 1 must be object".into()))?;
388    let b = args[1]
389        .as_object()
390        .ok_or_else(|| IntrinsicError("JsonMerge arg 2 must be object".into()))?;
391    let deep = args[2]
392        .as_bool()
393        .ok_or_else(|| IntrinsicError("JsonMerge arg 3 must be bool".into()))?;
394    let mut merged = a.clone();
395    if deep {
396        deep_merge(&mut merged, b);
397    } else {
398        for (k, v) in b {
399            merged.insert(k.clone(), v.clone());
400        }
401    }
402    Ok(Value::Object(merged))
403}
404
405fn deep_merge(a: &mut serde_json::Map<String, Value>, b: &serde_json::Map<String, Value>) {
406    for (k, v) in b {
407        match (a.get_mut(k), v) {
408            (Some(Value::Object(am)), Value::Object(bm)) => deep_merge(am, bm),
409            _ => {
410                a.insert(k.clone(), v.clone());
411            }
412        }
413    }
414}
415
416fn fn_math_random(args: &[Value]) -> Result<Value, IntrinsicError> {
417    use rand::Rng;
418    if args.len() < 2 || args.len() > 3 {
419        return Err(IntrinsicError(
420            "States.MathRandom expected 2 or 3 args".into(),
421        ));
422    }
423    let start = arg_as_i64(&args[0])?;
424    let end = arg_as_i64(&args[1])?;
425    if end <= start {
426        return Err(IntrinsicError("MathRandom end must be > start".into()));
427    }
428    // 3rd arg is an optional seed; we honour it for deterministic tests.
429    let v: i64 = if let Some(seed_v) = args.get(2) {
430        use rand::SeedableRng;
431        let seed = arg_as_i64(seed_v)? as u64;
432        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
433        rng.gen_range(start..end)
434    } else {
435        rand::thread_rng().gen_range(start..end)
436    };
437    Ok(json!(v))
438}
439
440fn fn_math_add(args: &[Value]) -> Result<Value, IntrinsicError> {
441    need_args(args, 2, "States.MathAdd")?;
442    let a = arg_as_i64(&args[0])?;
443    let b = arg_as_i64(&args[1])?;
444    Ok(json!(a + b))
445}
446
447fn fn_uuid(args: &[Value]) -> Result<Value, IntrinsicError> {
448    need_args(args, 0, "States.UUID")?;
449    Ok(Value::String(uuid::Uuid::new_v4().to_string()))
450}
451
452fn fn_string_split(args: &[Value]) -> Result<Value, IntrinsicError> {
453    need_args(args, 2, "States.StringSplit")?;
454    let s = arg_as_str(&args[0])?;
455    let splitter = arg_as_str(&args[1])?;
456    if splitter.is_empty() {
457        return Err(IntrinsicError(
458            "StringSplit delimiter must be non-empty".into(),
459        ));
460    }
461    // ASL StringSplit treats every char in the delimiter as a possible
462    // separator (eg. delimiter "., " splits on either dot, comma, or
463    // space) and drops empty tokens.
464    let chars: Vec<char> = splitter.chars().collect();
465    let parts: Vec<Value> = s
466        .split(|c: char| chars.contains(&c))
467        .filter(|p| !p.is_empty())
468        .map(|p| Value::String(p.to_string()))
469        .collect();
470    Ok(Value::Array(parts))
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use serde_json::json;
477
478    #[test]
479    fn format_substitutes_placeholders() {
480        let out = evaluate("States.Format('Hello, {}!', 'Alice')", &Value::Null).unwrap();
481        assert_eq!(out, json!("Hello, Alice!"));
482    }
483
484    #[test]
485    fn format_resolves_jsonpath_args() {
486        let input = json!({"name": "Bob", "n": 3});
487        let out = evaluate("States.Format('{}={}', $.name, $.n)", &input).unwrap();
488        assert_eq!(out, json!("Bob=3"));
489    }
490
491    #[test]
492    fn array_intrinsics() {
493        assert_eq!(
494            evaluate("States.Array(1, 2, 3)", &Value::Null).unwrap(),
495            json!([1, 2, 3])
496        );
497        assert_eq!(
498            evaluate("States.ArrayLength($)", &json!([10, 20, 30])).unwrap(),
499            json!(3)
500        );
501        assert_eq!(
502            evaluate("States.ArrayContains($, 2)", &json!([1, 2, 3])).unwrap(),
503            json!(true)
504        );
505        assert_eq!(
506            evaluate("States.ArrayContains($, 9)", &json!([1, 2, 3])).unwrap(),
507            json!(false)
508        );
509        assert_eq!(
510            evaluate("States.ArrayRange(1, 9, 2)", &Value::Null).unwrap(),
511            json!([1, 3, 5, 7, 9])
512        );
513        assert_eq!(
514            evaluate("States.ArrayPartition($, 2)", &json!([1, 2, 3, 4, 5])).unwrap(),
515            json!([[1, 2], [3, 4], [5]])
516        );
517        assert_eq!(
518            evaluate("States.ArrayGetItem($, 1)", &json!(["a", "b", "c"])).unwrap(),
519            json!("b")
520        );
521        assert_eq!(
522            evaluate("States.ArrayUnique($)", &json!([1, 2, 1, 3, 2])).unwrap(),
523            json!([1, 2, 3])
524        );
525    }
526
527    #[test]
528    fn json_intrinsics() {
529        assert_eq!(
530            evaluate("States.JsonToString($)", &json!({"x": 1})).unwrap(),
531            json!(r#"{"x":1}"#)
532        );
533        assert_eq!(
534            evaluate("States.StringToJson($)", &json!(r#"{"x":1}"#)).unwrap(),
535            json!({"x": 1})
536        );
537        assert_eq!(
538            evaluate(
539                "States.JsonMerge($.a, $.b, false)",
540                &json!({"a": {"x": 1, "y": 2}, "b": {"y": 9, "z": 3}})
541            )
542            .unwrap(),
543            json!({"x": 1, "y": 9, "z": 3})
544        );
545    }
546
547    #[test]
548    fn base64_intrinsics() {
549        let enc = evaluate("States.Base64Encode('hello')", &Value::Null).unwrap();
550        assert_eq!(enc, json!("aGVsbG8="));
551        let dec = evaluate("States.Base64Decode('aGVsbG8=')", &Value::Null).unwrap();
552        assert_eq!(dec, json!("hello"));
553    }
554
555    #[test]
556    fn hash_intrinsic() {
557        let out = evaluate("States.Hash('hello', 'SHA-256')", &Value::Null).unwrap();
558        assert_eq!(
559            out,
560            json!("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
561        );
562    }
563
564    #[test]
565    fn math_intrinsics() {
566        assert_eq!(
567            evaluate("States.MathAdd(2, 3)", &Value::Null).unwrap(),
568            json!(5)
569        );
570        let r = evaluate("States.MathRandom(0, 10)", &Value::Null).unwrap();
571        let n = r.as_i64().unwrap();
572        assert!((0..10).contains(&n));
573    }
574
575    #[test]
576    fn uuid_intrinsic_is_v4() {
577        let out = evaluate("States.UUID()", &Value::Null).unwrap();
578        let s = out.as_str().unwrap();
579        // 8-4-4-4-12 = 36 chars total
580        assert_eq!(s.len(), 36);
581        assert_eq!(s.chars().nth(14).unwrap(), '4');
582    }
583
584    #[test]
585    fn string_split_intrinsic() {
586        assert_eq!(
587            evaluate("States.StringSplit('a,b,c', ',')", &Value::Null).unwrap(),
588            json!(["a", "b", "c"])
589        );
590        // Multi-char delimiter splits on any contained char and drops
591        // empties.
592        assert_eq!(
593            evaluate("States.StringSplit('a,b c', ', ')", &Value::Null).unwrap(),
594            json!(["a", "b", "c"])
595        );
596    }
597
598    #[test]
599    fn detects_intrinsic_call() {
600        assert!(is_intrinsic_call("States.UUID()"));
601        assert!(is_intrinsic_call("States.Format('{}', $.x)"));
602        assert!(!is_intrinsic_call("$.foo.bar"));
603        assert!(!is_intrinsic_call("States.IntrinsicFailure"));
604    }
605
606    #[test]
607    fn unknown_intrinsic_errors() {
608        let err = evaluate("States.NoSuchFunction()", &Value::Null).unwrap_err();
609        assert!(format!("{err}").contains("unknown"));
610    }
611}