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 arg_as_f64(v: &Value) -> Result<f64, IntrinsicError> {
185    v.as_f64()
186        .ok_or_else(|| IntrinsicError(format!("expected number, got {v}")))
187}
188
189fn need_args(args: &[Value], expected: usize, name: &str) -> Result<(), IntrinsicError> {
190    if args.len() != expected {
191        Err(IntrinsicError(format!(
192            "{name} expected {expected} args, got {}",
193            args.len()
194        )))
195    } else {
196        Ok(())
197    }
198}
199
200fn fn_format(args: &[Value]) -> Result<Value, IntrinsicError> {
201    if args.is_empty() {
202        return Err(IntrinsicError(
203            "States.Format requires at least one argument".into(),
204        ));
205    }
206    let template = args[0]
207        .as_str()
208        .ok_or_else(|| IntrinsicError("States.Format template must be a string".into()))?;
209    let mut out = String::with_capacity(template.len());
210    let mut chars = template.chars().peekable();
211    let mut idx = 1;
212    while let Some(c) = chars.next() {
213        match c {
214            '\\' => {
215                if let Some(&n) = chars.peek() {
216                    out.push(n);
217                    chars.next();
218                }
219            }
220            '{' if matches!(chars.peek(), Some('}')) => {
221                chars.next();
222                let v = args.get(idx).ok_or_else(|| {
223                    IntrinsicError("States.Format placeholder count exceeds args".into())
224                })?;
225                idx += 1;
226                match v {
227                    Value::String(s) => out.push_str(s),
228                    Value::Null => out.push_str("null"),
229                    other => out.push_str(&serde_json::to_string(other).unwrap_or_default()),
230                }
231            }
232            _ => out.push(c),
233        }
234    }
235    Ok(Value::String(out))
236}
237
238fn fn_json_to_string(args: &[Value]) -> Result<Value, IntrinsicError> {
239    need_args(args, 1, "States.JsonToString")?;
240    Ok(Value::String(
241        serde_json::to_string(&args[0]).unwrap_or_default(),
242    ))
243}
244
245fn fn_string_to_json(args: &[Value]) -> Result<Value, IntrinsicError> {
246    need_args(args, 1, "States.StringToJson")?;
247    let s = args[0]
248        .as_str()
249        .ok_or_else(|| IntrinsicError("States.StringToJson arg must be a string".into()))?;
250    serde_json::from_str(s)
251        .map_err(|e| IntrinsicError(format!("States.StringToJson parse failed: {e}")))
252}
253
254fn fn_array_partition(args: &[Value]) -> Result<Value, IntrinsicError> {
255    need_args(args, 2, "States.ArrayPartition")?;
256    let arr = arg_as_array(&args[0])?;
257    let chunk = arg_as_i64(&args[1])?;
258    if chunk <= 0 {
259        return Err(IntrinsicError(
260            "ArrayPartition chunk size must be > 0".into(),
261        ));
262    }
263    let chunk = chunk as usize;
264    let mut out: Vec<Value> = Vec::new();
265    for slice in arr.chunks(chunk) {
266        out.push(Value::Array(slice.to_vec()));
267    }
268    Ok(Value::Array(out))
269}
270
271fn fn_array_contains(args: &[Value]) -> Result<Value, IntrinsicError> {
272    need_args(args, 2, "States.ArrayContains")?;
273    let arr = arg_as_array(&args[0])?;
274    Ok(Value::Bool(arr.iter().any(|v| v == &args[1])))
275}
276
277fn fn_array_range(args: &[Value]) -> Result<Value, IntrinsicError> {
278    need_args(args, 3, "States.ArrayRange")?;
279    let start = arg_as_i64(&args[0])?;
280    let end = arg_as_i64(&args[1])?;
281    let step = arg_as_i64(&args[2])?;
282    if step == 0 {
283        return Err(IntrinsicError("ArrayRange step must be != 0".into()));
284    }
285    let mut out = Vec::new();
286    let mut i = start;
287    if step > 0 {
288        while i <= end {
289            out.push(json!(i));
290            i += step;
291        }
292    } else {
293        while i >= end {
294            out.push(json!(i));
295            i += step;
296        }
297    }
298    Ok(Value::Array(out))
299}
300
301fn fn_array_get_item(args: &[Value]) -> Result<Value, IntrinsicError> {
302    need_args(args, 2, "States.ArrayGetItem")?;
303    let arr = arg_as_array(&args[0])?;
304    let idx = arg_as_i64(&args[1])?;
305    if idx < 0 {
306        return Err(IntrinsicError("ArrayGetItem index must be >= 0".into()));
307    }
308    Ok(arr.get(idx as usize).cloned().unwrap_or(Value::Null))
309}
310
311fn fn_array_length(args: &[Value]) -> Result<Value, IntrinsicError> {
312    need_args(args, 1, "States.ArrayLength")?;
313    let arr = arg_as_array(&args[0])?;
314    Ok(json!(arr.len()))
315}
316
317fn fn_array_unique(args: &[Value]) -> Result<Value, IntrinsicError> {
318    need_args(args, 1, "States.ArrayUnique")?;
319    let arr = arg_as_array(&args[0])?;
320    let mut seen: Vec<Value> = Vec::new();
321    for v in arr {
322        if !seen.contains(v) {
323            seen.push(v.clone());
324        }
325    }
326    Ok(Value::Array(seen))
327}
328
329fn fn_base64_encode(args: &[Value]) -> Result<Value, IntrinsicError> {
330    need_args(args, 1, "States.Base64Encode")?;
331    let s = arg_as_str(&args[0])?;
332    Ok(Value::String(
333        base64::engine::general_purpose::STANDARD.encode(s.as_bytes()),
334    ))
335}
336
337fn fn_base64_decode(args: &[Value]) -> Result<Value, IntrinsicError> {
338    need_args(args, 1, "States.Base64Decode")?;
339    let s = arg_as_str(&args[0])?;
340    let bytes = base64::engine::general_purpose::STANDARD
341        .decode(s.as_bytes())
342        .map_err(|e| IntrinsicError(format!("Base64Decode failed: {e}")))?;
343    let decoded = String::from_utf8(bytes)
344        .map_err(|e| IntrinsicError(format!("Base64Decode utf8 failed: {e}")))?;
345    Ok(Value::String(decoded))
346}
347
348fn fn_hash(args: &[Value]) -> Result<Value, IntrinsicError> {
349    use md5::Digest;
350    need_args(args, 2, "States.Hash")?;
351    let input = arg_as_str(&args[0])?;
352    let algo = arg_as_str(&args[1])?;
353    let digest_hex = match algo.as_str() {
354        "MD5" => {
355            let mut h = md5::Md5::new();
356            h.update(input.as_bytes());
357            hex::encode(h.finalize())
358        }
359        "SHA-1" => {
360            let mut h = sha1::Sha1::new();
361            h.update(input.as_bytes());
362            hex::encode(h.finalize())
363        }
364        "SHA-256" => {
365            let mut h = sha2::Sha256::new();
366            h.update(input.as_bytes());
367            hex::encode(h.finalize())
368        }
369        "SHA-384" => {
370            let mut h = sha2::Sha384::new();
371            h.update(input.as_bytes());
372            hex::encode(h.finalize())
373        }
374        "SHA-512" => {
375            let mut h = sha2::Sha512::new();
376            h.update(input.as_bytes());
377            hex::encode(h.finalize())
378        }
379        other => {
380            return Err(IntrinsicError(format!(
381                "unsupported hash algorithm '{other}'"
382            )))
383        }
384    };
385    Ok(Value::String(digest_hex))
386}
387
388fn fn_json_merge(args: &[Value]) -> Result<Value, IntrinsicError> {
389    need_args(args, 3, "States.JsonMerge")?;
390    let a = args[0]
391        .as_object()
392        .ok_or_else(|| IntrinsicError("JsonMerge arg 1 must be object".into()))?;
393    let b = args[1]
394        .as_object()
395        .ok_or_else(|| IntrinsicError("JsonMerge arg 2 must be object".into()))?;
396    let deep = args[2]
397        .as_bool()
398        .ok_or_else(|| IntrinsicError("JsonMerge arg 3 must be bool".into()))?;
399    let mut merged = a.clone();
400    if deep {
401        deep_merge(&mut merged, b);
402    } else {
403        for (k, v) in b {
404            merged.insert(k.clone(), v.clone());
405        }
406    }
407    Ok(Value::Object(merged))
408}
409
410fn deep_merge(a: &mut serde_json::Map<String, Value>, b: &serde_json::Map<String, Value>) {
411    for (k, v) in b {
412        match (a.get_mut(k), v) {
413            (Some(Value::Object(am)), Value::Object(bm)) => deep_merge(am, bm),
414            _ => {
415                a.insert(k.clone(), v.clone());
416            }
417        }
418    }
419}
420
421fn fn_math_random(args: &[Value]) -> Result<Value, IntrinsicError> {
422    use rand::Rng;
423    if args.len() < 2 || args.len() > 3 {
424        return Err(IntrinsicError(
425            "States.MathRandom expected 2 or 3 args".into(),
426        ));
427    }
428    let start = arg_as_i64(&args[0])?;
429    let end = arg_as_i64(&args[1])?;
430    if end <= start {
431        return Err(IntrinsicError("MathRandom end must be > start".into()));
432    }
433    // 3rd arg is an optional seed; we honour it for deterministic tests.
434    let v: i64 = if let Some(seed_v) = args.get(2) {
435        use rand::SeedableRng;
436        let seed = arg_as_i64(seed_v)? as u64;
437        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
438        rng.gen_range(start..end)
439    } else {
440        rand::thread_rng().gen_range(start..end)
441    };
442    Ok(json!(v))
443}
444
445fn fn_math_add(args: &[Value]) -> Result<Value, IntrinsicError> {
446    need_args(args, 2, "States.MathAdd")?;
447    // Integer operands add with 64-bit integer semantics, but a genuine
448    // overflow errors instead of panicking (debug) or silently wrapping
449    // (release).
450    if let (Some(a), Some(b)) = (args[0].as_i64(), args[1].as_i64()) {
451        return match a.checked_add(b) {
452            Some(sum) => Ok(json!(sum)),
453            None => Err(IntrinsicError(
454                "States.MathAdd result overflows a 64-bit integer".into(),
455            )),
456        };
457    }
458    // Otherwise fall back to floating-point addition. This covers fractional
459    // operands (which the old i64 coercion truncated) and integers too large
460    // for i64. AWS treats numbers as given, so no truncation is applied.
461    let a = arg_as_f64(&args[0])?;
462    let b = arg_as_f64(&args[1])?;
463    Ok(json!(a + b))
464}
465
466fn fn_uuid(args: &[Value]) -> Result<Value, IntrinsicError> {
467    need_args(args, 0, "States.UUID")?;
468    Ok(Value::String(uuid::Uuid::new_v4().to_string()))
469}
470
471fn fn_string_split(args: &[Value]) -> Result<Value, IntrinsicError> {
472    need_args(args, 2, "States.StringSplit")?;
473    let s = arg_as_str(&args[0])?;
474    let splitter = arg_as_str(&args[1])?;
475    if splitter.is_empty() {
476        return Err(IntrinsicError(
477            "StringSplit delimiter must be non-empty".into(),
478        ));
479    }
480    // ASL StringSplit treats every char in the delimiter as a possible
481    // separator (eg. delimiter "., " splits on either dot, comma, or
482    // space) and drops empty tokens.
483    let chars: Vec<char> = splitter.chars().collect();
484    let parts: Vec<Value> = s
485        .split(|c: char| chars.contains(&c))
486        .filter(|p| !p.is_empty())
487        .map(|p| Value::String(p.to_string()))
488        .collect();
489    Ok(Value::Array(parts))
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use serde_json::json;
496
497    #[test]
498    fn format_substitutes_placeholders() {
499        let out = evaluate("States.Format('Hello, {}!', 'Alice')", &Value::Null).unwrap();
500        assert_eq!(out, json!("Hello, Alice!"));
501    }
502
503    #[test]
504    fn format_resolves_jsonpath_args() {
505        let input = json!({"name": "Bob", "n": 3});
506        let out = evaluate("States.Format('{}={}', $.name, $.n)", &input).unwrap();
507        assert_eq!(out, json!("Bob=3"));
508    }
509
510    #[test]
511    fn array_intrinsics() {
512        assert_eq!(
513            evaluate("States.Array(1, 2, 3)", &Value::Null).unwrap(),
514            json!([1, 2, 3])
515        );
516        assert_eq!(
517            evaluate("States.ArrayLength($)", &json!([10, 20, 30])).unwrap(),
518            json!(3)
519        );
520        assert_eq!(
521            evaluate("States.ArrayContains($, 2)", &json!([1, 2, 3])).unwrap(),
522            json!(true)
523        );
524        assert_eq!(
525            evaluate("States.ArrayContains($, 9)", &json!([1, 2, 3])).unwrap(),
526            json!(false)
527        );
528        assert_eq!(
529            evaluate("States.ArrayRange(1, 9, 2)", &Value::Null).unwrap(),
530            json!([1, 3, 5, 7, 9])
531        );
532        assert_eq!(
533            evaluate("States.ArrayPartition($, 2)", &json!([1, 2, 3, 4, 5])).unwrap(),
534            json!([[1, 2], [3, 4], [5]])
535        );
536        assert_eq!(
537            evaluate("States.ArrayGetItem($, 1)", &json!(["a", "b", "c"])).unwrap(),
538            json!("b")
539        );
540        assert_eq!(
541            evaluate("States.ArrayUnique($)", &json!([1, 2, 1, 3, 2])).unwrap(),
542            json!([1, 2, 3])
543        );
544    }
545
546    #[test]
547    fn json_intrinsics() {
548        assert_eq!(
549            evaluate("States.JsonToString($)", &json!({"x": 1})).unwrap(),
550            json!(r#"{"x":1}"#)
551        );
552        assert_eq!(
553            evaluate("States.StringToJson($)", &json!(r#"{"x":1}"#)).unwrap(),
554            json!({"x": 1})
555        );
556        assert_eq!(
557            evaluate(
558                "States.JsonMerge($.a, $.b, false)",
559                &json!({"a": {"x": 1, "y": 2}, "b": {"y": 9, "z": 3}})
560            )
561            .unwrap(),
562            json!({"x": 1, "y": 9, "z": 3})
563        );
564    }
565
566    #[test]
567    fn base64_intrinsics() {
568        let enc = evaluate("States.Base64Encode('hello')", &Value::Null).unwrap();
569        assert_eq!(enc, json!("aGVsbG8="));
570        let dec = evaluate("States.Base64Decode('aGVsbG8=')", &Value::Null).unwrap();
571        assert_eq!(dec, json!("hello"));
572    }
573
574    #[test]
575    fn hash_intrinsic() {
576        let out = evaluate("States.Hash('hello', 'SHA-256')", &Value::Null).unwrap();
577        assert_eq!(
578            out,
579            json!("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
580        );
581    }
582
583    #[test]
584    fn math_intrinsics() {
585        assert_eq!(
586            evaluate("States.MathAdd(2, 3)", &Value::Null).unwrap(),
587            json!(5)
588        );
589        let r = evaluate("States.MathRandom(0, 10)", &Value::Null).unwrap();
590        let n = r.as_i64().unwrap();
591        assert!((0..10).contains(&n));
592    }
593
594    // L6: MathAdd must not panic (debug) or wrap (release) on i64 overflow, and
595    // must not truncate fractional operands.
596    #[test]
597    fn math_add_overflow_and_floats() {
598        // i64::MAX + 1 overflows → error rather than panic/wrap.
599        let expr = format!("States.MathAdd({}, 1)", i64::MAX);
600        assert!(evaluate(&expr, &Value::Null).is_err());
601
602        // Fractional operands are preserved, not truncated to int.
603        assert_eq!(
604            fn_math_add(&[json!(1.5), json!(2.25)]).unwrap(),
605            json!(3.75)
606        );
607        // Mixed int + float stays a float.
608        assert_eq!(fn_math_add(&[json!(2), json!(0.5)]).unwrap(), json!(2.5));
609        // Negative integers still work.
610        assert_eq!(fn_math_add(&[json!(-4), json!(1)]).unwrap(), json!(-3));
611    }
612
613    #[test]
614    fn uuid_intrinsic_is_v4() {
615        let out = evaluate("States.UUID()", &Value::Null).unwrap();
616        let s = out.as_str().unwrap();
617        // 8-4-4-4-12 = 36 chars total
618        assert_eq!(s.len(), 36);
619        assert_eq!(s.chars().nth(14).unwrap(), '4');
620    }
621
622    #[test]
623    fn string_split_intrinsic() {
624        assert_eq!(
625            evaluate("States.StringSplit('a,b,c', ',')", &Value::Null).unwrap(),
626            json!(["a", "b", "c"])
627        );
628        // Multi-char delimiter splits on any contained char and drops
629        // empties.
630        assert_eq!(
631            evaluate("States.StringSplit('a,b c', ', ')", &Value::Null).unwrap(),
632            json!(["a", "b", "c"])
633        );
634    }
635
636    #[test]
637    fn detects_intrinsic_call() {
638        assert!(is_intrinsic_call("States.UUID()"));
639        assert!(is_intrinsic_call("States.Format('{}', $.x)"));
640        assert!(!is_intrinsic_call("$.foo.bar"));
641        assert!(!is_intrinsic_call("States.IntrinsicFailure"));
642    }
643
644    #[test]
645    fn unknown_intrinsic_errors() {
646        let err = evaluate("States.NoSuchFunction()", &Value::Null).unwrap_err();
647        assert!(format!("{err}").contains("unknown"));
648    }
649}