Skip to main content

template_model/
coercion.rs

1//! CloudFormation stringifies all scalar values before sending them to resource
2//! handlers, so `512` and `"512"` are equivalent. YAML 1.1 also parses `yes`/`no`
3//! as booleans. These helpers implement the same loose type semantics so that
4//! constraint checks (min/max, enum, pattern, length) work on coerced values.
5
6use serde_json::Value;
7
8pub fn coerce_to_number(val: &Value) -> Option<f64> {
9    match val {
10        Value::Number(n) => n.as_f64(),
11        Value::String(s) => s.trim().parse::<f64>().ok(),
12        _ => None,
13    }
14}
15
16pub fn coerce_to_integer(val: &Value) -> Option<i64> {
17    match val {
18        Value::Number(n) => n.as_i64().or_else(|| {
19            let f = n.as_f64()?;
20            if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 { Some(f as i64) } else { None }
21        }),
22        Value::String(s) => coerce_str_to_integer(s),
23        _ => None,
24    }
25}
26
27/// The string arm of [`coerce_to_integer`], for callers that hold a scalar
28/// outside a `serde_json::Value` (such as the parser's `ParseValue` values).
29pub fn coerce_str_to_integer(s: &str) -> Option<i64> {
30    let s = s.trim();
31    s.parse::<i64>().ok().or_else(|| {
32        let f = s.parse::<f64>().ok()?;
33        if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 { Some(f as i64) } else { None }
34    })
35}
36
37pub fn coerce_to_string(val: &Value) -> Option<String> {
38    match val {
39        Value::String(s) => Some(s.clone()),
40        Value::Bool(b) => Some(if *b { "true" } else { "false" }.into()),
41        Value::Number(n) => {
42            if let Some(i) = n.as_i64() {
43                Some(i.to_string())
44            } else {
45                Some(n.to_string())
46            }
47        }
48        _ => None,
49    }
50}
51
52/// Returns the text authored for a string or integer scalar. Fractional numbers,
53/// booleans, nulls, and composite values have no string-or-integer form.
54pub fn coerce_string_or_integer_to_string(value: &Value) -> Option<String> {
55    match value {
56        Value::String(text) => Some(text.clone()),
57        Value::Number(number) => number
58            .as_i64()
59            .map(|integer| integer.to_string())
60            .or_else(|| number.as_u64().map(|integer| integer.to_string())),
61        _ => None,
62    }
63}
64
65/// Accepts YAML 1.1 boolean strings in addition to native bools.
66///
67/// CloudFormation uses a YAML 1.1 parser which treats `yes`/`no`/`on`/`off`
68/// (and their case variants) as booleans. This function matches that behavior
69/// so that schema validation doesn't reject values the service would accept.
70///
71/// Only the three standard YAML 1.1 casings are accepted per variant
72/// (lowercase, Titlecase, UPPERCASE). Non-spec casings like `"yEs"` are rejected.
73pub fn coerce_to_bool(val: &Value) -> Option<bool> {
74    match val {
75        Value::Bool(b) => Some(*b),
76        Value::String(s) => match s.as_str() {
77            "true" | "True" | "TRUE" | "yes" | "Yes" | "YES" | "on" | "On" | "ON" | "y" | "Y" | "1" => Some(true),
78            "false" | "False" | "FALSE" | "no" | "No" | "NO" | "off" | "Off" | "OFF" | "n" | "N" | "0" => Some(false),
79            _ => None,
80        },
81        _ => None,
82    }
83}
84
85/// Stringify a value for port-style comparison, accepting only string and
86/// integer scalars.
87///
88/// Some rules compare port-like values (host/container ports, target-group
89/// ports) by their string form - a quoted `"80"` and an unquoted `80` are the
90/// same port. Only strings and integers participate: a non-integer number
91/// (e.g. `80.0`), a boolean, or a composite is not a valid port form and yields
92/// `None` so the comparison is skipped rather than matched on a coerced value.
93/// An integer-valued float such as `80.0` is deliberately excluded - the value
94/// is only treated as a port when written as an integer or a string.
95pub fn coerce_port_to_string(val: &Value) -> Option<String> {
96    match val {
97        Value::String(s) => Some(s.clone()),
98        Value::Number(n) => n.as_i64().map(|i| i.to_string()).or_else(|| n.as_u64().map(|u| u.to_string())),
99        _ => None,
100    }
101}
102
103/// Whether two values are equal under CloudFormation's loose scalar semantics.
104///
105/// CloudFormation stringifies scalar values before comparison, so `512` and
106/// `"512"`, or `true` and `"true"`, are equal. Numeric scalars compare
107/// numerically when either operand is a native number, so `30`, `30.0`, and
108/// `"30.0"` are all the same value - an integral float must never mismatch its
109/// integer form just because they stringify differently. Two string operands
110/// keep exact string comparison. Non-scalar values have no coercion, so
111/// distinct arrays/objects/nulls never collapse together - only native
112/// equality can make them match.
113pub fn scalar_eq(a: &Value, b: &Value) -> bool {
114    if a == b {
115        return true;
116    }
117    if (a.is_number() || b.is_number())
118        && let (Some(na), Some(nb)) = (coerce_to_number(a), coerce_to_number(b))
119    {
120        return na == nb;
121    }
122    match (coerce_to_string(a), coerce_to_string(b)) {
123        (Some(sa), Some(sb)) => sa == sb,
124        _ => false,
125    }
126}
127
128/// Check if a value is compatible with the expected JSON Schema type using
129/// CFN's loose semantics.
130///
131/// - `"string"`: anything except object/array/null
132/// - `"integer"`: anything parseable as a whole number (not bool)
133/// - `"number"`: anything parseable as a number (not bool)
134/// - `"boolean"`: native bool or YAML 1.1 boolean strings (true/false/yes/no/on/off/y/n/1/0 with standard casings)
135/// - `"object"`, `"array"`, `"null"`: strict native type only
136pub fn type_compatible(val: &Value, expected: &str) -> bool {
137    match expected {
138        // Type CHECK is strict: integer/boolean are NOT type "string".
139        // Coercion for constraint evaluation uses coerce_to_string separately.
140        "string" => val.is_string(),
141        "integer" => {
142            if val.is_boolean() {
143                return false;
144            }
145            coerce_to_integer(val).is_some()
146        }
147        "number" => {
148            if val.is_boolean() {
149                return false;
150            }
151            coerce_to_number(val).is_some()
152        }
153        "boolean" => coerce_to_bool(val).is_some(),
154        "object" => val.is_object(),
155        "array" => val.is_array(),
156        "null" => val.is_null(),
157        _ => false,
158    }
159}
160
161/// Result of attempting to coerce a JSON value to match an expected schema type.
162#[derive(Debug, Clone, PartialEq)]
163pub enum CoerceResult {
164    /// Value already matches the expected type - no coercion needed.
165    AlreadyCorrect,
166    /// Value was coerced to the expected type. Contains the coerced value and
167    /// a human-readable description of the conversion (e.g. "string to integer").
168    Coerced(Value, String),
169    /// Value cannot be coerced to the expected type.
170    Failed,
171}
172
173/// Attempt to coerce a JSON value to match the expected schema type.
174///
175/// Returns `AlreadyCorrect` if the native type matches, `Coerced` with the
176/// converted value if CloudFormation would silently accept the mismatch, or
177/// `Failed` if the types are incompatible.
178pub fn coerce_value(val: &Value, expected: &str) -> CoerceResult {
179    let native_match = match expected {
180        "string" => val.is_string(),
181        "integer" => {
182            val.is_i64() || val.is_u64() || (val.is_f64() && val.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false))
183        }
184        "number" | "double" | "float" => val.is_number(),
185        "boolean" => val.is_boolean(),
186        "object" => val.is_object(),
187        "array" => val.is_array(),
188        "null" => val.is_null(),
189        _ => return CoerceResult::Failed,
190    };
191    if native_match {
192        return CoerceResult::AlreadyCorrect;
193    }
194
195    match expected {
196        "string" => coerce_to_string(val)
197            .map(|s| {
198                let from = if val.is_boolean() { "boolean" } else { "number" };
199                CoerceResult::Coerced(Value::String(s), format!("{from} to string"))
200            })
201            .unwrap_or(CoerceResult::Failed),
202        "integer" => {
203            if val.is_boolean() {
204                return CoerceResult::Failed;
205            }
206            coerce_to_integer(val)
207                .map(|i| CoerceResult::Coerced(Value::Number(i.into()), "string to integer".into()))
208                .unwrap_or(CoerceResult::Failed)
209        }
210        "number" | "double" | "float" => {
211            if val.is_boolean() {
212                return CoerceResult::Failed;
213            }
214            coerce_to_number(val)
215                .and_then(|f| {
216                    serde_json::Number::from_f64(f)
217                        .map(|n| CoerceResult::Coerced(Value::Number(n), "string to number".into()))
218                })
219                .unwrap_or(CoerceResult::Failed)
220        }
221        "boolean" => coerce_to_bool(val)
222            .map(|b| CoerceResult::Coerced(Value::Bool(b), "string to boolean".into()))
223            .unwrap_or(CoerceResult::Failed),
224        _ => CoerceResult::Failed,
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use serde_json::json;
232
233    #[test]
234    fn coerce_to_number_from_string() {
235        assert_eq!(coerce_to_number(&json!("512")), Some(512.0));
236        assert_eq!(coerce_to_number(&json!("3.14")), Some(3.14));
237        assert_eq!(coerce_to_number(&json!(" 42 ")), Some(42.0));
238        assert_eq!(coerce_to_number(&json!("abc")), None);
239        assert_eq!(coerce_to_number(&json!("")), None);
240    }
241
242    #[test]
243    fn coerce_to_number_from_number() {
244        assert_eq!(coerce_to_number(&json!(512)), Some(512.0));
245        assert_eq!(coerce_to_number(&json!(3.14)), Some(3.14));
246    }
247
248    #[test]
249    fn coerce_to_number_rejects_non_scalars() {
250        assert_eq!(coerce_to_number(&json!(true)), None);
251        assert_eq!(coerce_to_number(&json!(null)), None);
252        assert_eq!(coerce_to_number(&json!([1])), None);
253        assert_eq!(coerce_to_number(&json!({"a": 1})), None);
254    }
255
256    #[test]
257    fn coerce_to_integer_from_string() {
258        assert_eq!(coerce_to_integer(&json!("512")), Some(512));
259        assert_eq!(coerce_to_integer(&json!("3.0")), Some(3));
260        assert_eq!(coerce_to_integer(&json!("3.5")), None);
261        assert_eq!(coerce_to_integer(&json!("abc")), None);
262    }
263
264    #[test]
265    fn coerce_to_string_from_bool() {
266        assert_eq!(coerce_to_string(&json!(true)), Some("true".into()));
267        assert_eq!(coerce_to_string(&json!(false)), Some("false".into()));
268    }
269
270    #[test]
271    fn coerce_to_string_from_number() {
272        assert_eq!(coerce_to_string(&json!(512)), Some("512".into()));
273    }
274
275    #[test]
276    fn coerce_to_string_rejects_complex() {
277        assert_eq!(coerce_to_string(&json!(null)), None);
278        assert_eq!(coerce_to_string(&json!([1])), None);
279        assert_eq!(coerce_to_string(&json!({"a": 1})), None);
280    }
281
282    #[test]
283    fn coerce_to_bool_from_string() {
284        assert_eq!(coerce_to_bool(&json!("true")), Some(true));
285        assert_eq!(coerce_to_bool(&json!("True")), Some(true));
286        assert_eq!(coerce_to_bool(&json!("TRUE")), Some(true));
287        assert_eq!(coerce_to_bool(&json!("false")), Some(false));
288        assert_eq!(coerce_to_bool(&json!("False")), Some(false));
289        assert_eq!(coerce_to_bool(&json!("FALSE")), Some(false));
290    }
291
292    #[test]
293    fn coerce_to_bool_yaml11_true_variants() {
294        assert_eq!(coerce_to_bool(&json!("yes")), Some(true));
295        assert_eq!(coerce_to_bool(&json!("Yes")), Some(true));
296        assert_eq!(coerce_to_bool(&json!("YES")), Some(true));
297        assert_eq!(coerce_to_bool(&json!("on")), Some(true));
298        assert_eq!(coerce_to_bool(&json!("On")), Some(true));
299        assert_eq!(coerce_to_bool(&json!("ON")), Some(true));
300        assert_eq!(coerce_to_bool(&json!("y")), Some(true));
301        assert_eq!(coerce_to_bool(&json!("Y")), Some(true));
302        assert_eq!(coerce_to_bool(&json!("1")), Some(true));
303    }
304
305    #[test]
306    fn coerce_to_bool_yaml11_false_variants() {
307        assert_eq!(coerce_to_bool(&json!("no")), Some(false));
308        assert_eq!(coerce_to_bool(&json!("No")), Some(false));
309        assert_eq!(coerce_to_bool(&json!("NO")), Some(false));
310        assert_eq!(coerce_to_bool(&json!("off")), Some(false));
311        assert_eq!(coerce_to_bool(&json!("Off")), Some(false));
312        assert_eq!(coerce_to_bool(&json!("OFF")), Some(false));
313        assert_eq!(coerce_to_bool(&json!("n")), Some(false));
314        assert_eq!(coerce_to_bool(&json!("N")), Some(false));
315        assert_eq!(coerce_to_bool(&json!("0")), Some(false));
316    }
317
318    #[test]
319    fn coerce_to_bool_rejects_nonspec_casings() {
320        assert_eq!(coerce_to_bool(&json!("tRuE")), None);
321        assert_eq!(coerce_to_bool(&json!("yEs")), None);
322        assert_eq!(coerce_to_bool(&json!("oN")), None);
323        assert_eq!(coerce_to_bool(&json!("fAlSe")), None);
324        assert_eq!(coerce_to_bool(&json!("nO")), None);
325        assert_eq!(coerce_to_bool(&json!("oFf")), None);
326        assert_eq!(coerce_to_bool(&json!("maybe")), None);
327        assert_eq!(coerce_to_bool(&json!("")), None);
328        assert_eq!(coerce_to_bool(&json!("2")), None);
329    }
330
331    #[test]
332    fn coerce_port_to_string_accepts_string_and_integer_only() {
333        assert_eq!(coerce_port_to_string(&json!("80")), Some("80".into()));
334        assert_eq!(coerce_port_to_string(&json!(80)), Some("80".into()));
335        assert_eq!(coerce_port_to_string(&json!(0)), Some("0".into()));
336        // Non-integer numbers, booleans, and composites are not valid port forms.
337        assert_eq!(coerce_port_to_string(&json!(80.0)), None);
338        assert_eq!(coerce_port_to_string(&json!(80.5)), None);
339        assert_eq!(coerce_port_to_string(&json!(true)), None);
340        assert_eq!(coerce_port_to_string(&json!(null)), None);
341        assert_eq!(coerce_port_to_string(&json!([80])), None);
342    }
343
344    #[test]
345    fn scalar_eq_native_and_coerced_scalars() {
346        assert!(scalar_eq(&json!(512), &json!("512")));
347        assert!(scalar_eq(&json!("512"), &json!(512)));
348        assert!(scalar_eq(&json!(true), &json!("true")));
349        assert!(scalar_eq(&json!("hello"), &json!("hello")));
350        assert!(!scalar_eq(&json!(512), &json!("513")));
351        assert!(!scalar_eq(&json!(true), &json!("false")));
352    }
353
354    #[test]
355    fn scalar_eq_integral_float_equals_integer() {
356        // A whole number is the same value in every written form -
357        // 30, 30.0, and "30.0" must all compare equal (issue: a Number
358        // parameter resolving to 30.0 failed the integer enum [.., 30, ..]).
359        assert!(scalar_eq(&json!(30.0), &json!(30)), "30.0 must equal 30");
360        assert!(scalar_eq(&json!(30), &json!(30.0)), "30 must equal 30.0");
361        assert!(scalar_eq(&json!(30.0), &json!("30")), "30.0 must equal '30'");
362        assert!(scalar_eq(&json!("30.0"), &json!(30)), "'30.0' must equal 30");
363        assert!(!scalar_eq(&json!(30.5), &json!(30)), "30.5 must not equal 30");
364        assert!(!scalar_eq(&json!(30.0), &json!(31)), "30.0 must not equal 31");
365    }
366
367    #[test]
368    fn scalar_eq_non_numeric_string_keeps_exact_comparison() {
369        assert!(!scalar_eq(&json!("30x"), &json!(30)), "'30x' is not a number and must not equal 30");
370        assert!(!scalar_eq(&json!("tcp"), &json!("TCP")), "string comparison stays case-sensitive");
371    }
372
373    #[test]
374    fn scalar_eq_distinct_non_scalars_never_collapse() {
375        // Arrays/objects/null have no string coercion; only native equality can
376        // make them match. Distinct composites must NOT be treated as equal.
377        assert!(!scalar_eq(&json!([1]), &json!([2])));
378        assert!(!scalar_eq(&json!({"a": 1}), &json!({"b": 2})));
379        assert!(!scalar_eq(&json!([1]), &json!("[1]")));
380        assert!(!scalar_eq(&json!(null), &json!("null")));
381        // Native equality still holds for identical composites/null.
382        assert!(scalar_eq(&json!([1, 2]), &json!([1, 2])));
383        assert!(scalar_eq(&json!(null), &json!(null)));
384    }
385
386    #[test]
387    fn type_compatible_string() {
388        assert!(type_compatible(&json!("hello"), "string"));
389        assert!(!type_compatible(&json!(512), "string"));
390        assert!(!type_compatible(&json!(true), "string"));
391        assert!(!type_compatible(&json!(null), "string"));
392        assert!(!type_compatible(&json!([1]), "string"));
393        assert!(!type_compatible(&json!({"a": 1}), "string"));
394    }
395
396    #[test]
397    fn type_compatible_integer() {
398        assert!(type_compatible(&json!(512), "integer"));
399        assert!(type_compatible(&json!("512"), "integer"));
400        assert!(!type_compatible(&json!("3.5"), "integer"));
401        assert!(!type_compatible(&json!("abc"), "integer"));
402        assert!(!type_compatible(&json!(true), "integer"));
403    }
404
405    #[test]
406    fn type_compatible_number() {
407        assert!(type_compatible(&json!(3.14), "number"));
408        assert!(type_compatible(&json!("3.14"), "number"));
409        assert!(type_compatible(&json!(512), "number"));
410        assert!(!type_compatible(&json!("abc"), "number"));
411        assert!(!type_compatible(&json!(true), "number"));
412    }
413
414    #[test]
415    fn type_compatible_boolean() {
416        assert!(type_compatible(&json!(true), "boolean"));
417        assert!(type_compatible(&json!("true"), "boolean"));
418        assert!(type_compatible(&json!("FALSE"), "boolean"));
419        assert!(type_compatible(&json!("yes"), "boolean"));
420        assert!(type_compatible(&json!("Yes"), "boolean"));
421        assert!(type_compatible(&json!("on"), "boolean"));
422        assert!(type_compatible(&json!("OFF"), "boolean"));
423        assert!(type_compatible(&json!("y"), "boolean"));
424        assert!(type_compatible(&json!("N"), "boolean"));
425        assert!(type_compatible(&json!("1"), "boolean"));
426        assert!(type_compatible(&json!("0"), "boolean"));
427        assert!(!type_compatible(&json!(1), "boolean"));
428        assert!(!type_compatible(&json!("maybe"), "boolean"));
429    }
430
431    #[test]
432    fn type_compatible_object_array_null() {
433        assert!(type_compatible(&json!({}), "object"));
434        assert!(!type_compatible(&json!("s"), "object"));
435        assert!(type_compatible(&json!([]), "array"));
436        assert!(!type_compatible(&json!(1), "array"));
437        assert!(type_compatible(&json!(null), "null"));
438        assert!(!type_compatible(&json!(""), "null"));
439    }
440
441    #[test]
442    fn type_compatible_unknown_type_returns_false() {
443        assert!(!type_compatible(&json!("x"), "foobar"));
444    }
445
446    #[test]
447    fn coerce_value_already_correct() {
448        assert_eq!(coerce_value(&json!("hello"), "string"), CoerceResult::AlreadyCorrect);
449        assert_eq!(coerce_value(&json!(42), "integer"), CoerceResult::AlreadyCorrect);
450        assert_eq!(coerce_value(&json!(3.14), "number"), CoerceResult::AlreadyCorrect);
451        assert_eq!(coerce_value(&json!(true), "boolean"), CoerceResult::AlreadyCorrect);
452        assert_eq!(coerce_value(&json!({}), "object"), CoerceResult::AlreadyCorrect);
453        assert_eq!(coerce_value(&json!([]), "array"), CoerceResult::AlreadyCorrect);
454        assert_eq!(coerce_value(&json!(null), "null"), CoerceResult::AlreadyCorrect);
455    }
456
457    #[test]
458    fn coerce_value_string_to_integer() {
459        match coerce_value(&json!("42"), "integer") {
460            CoerceResult::Coerced(v, desc) => {
461                assert_eq!(v, json!(42));
462                assert!(desc.contains("integer"));
463            }
464            other => panic!("expected Coerced, got {:?}", other),
465        }
466    }
467
468    #[test]
469    fn coerce_value_string_to_number() {
470        match coerce_value(&json!("3.14"), "number") {
471            CoerceResult::Coerced(v, _) => assert_eq!(v.as_f64().unwrap(), 3.14),
472            other => panic!("expected Coerced, got {:?}", other),
473        }
474    }
475
476    #[test]
477    fn coerce_value_string_to_boolean() {
478        match coerce_value(&json!("yes"), "boolean") {
479            CoerceResult::Coerced(v, _) => assert_eq!(v, json!(true)),
480            other => panic!("expected Coerced, got {:?}", other),
481        }
482    }
483
484    #[test]
485    fn coerce_value_bool_to_string() {
486        match coerce_value(&json!(true), "string") {
487            CoerceResult::Coerced(v, desc) => {
488                assert_eq!(v, json!("true"));
489                assert!(desc.contains("boolean"));
490            }
491            other => panic!("expected Coerced, got {:?}", other),
492        }
493    }
494
495    #[test]
496    fn coerce_value_number_to_string() {
497        match coerce_value(&json!(42), "string") {
498            CoerceResult::Coerced(v, desc) => {
499                assert_eq!(v, json!("42"));
500                assert!(desc.contains("number"));
501            }
502            other => panic!("expected Coerced, got {:?}", other),
503        }
504    }
505
506    #[test]
507    fn coerce_value_bool_to_integer_fails() {
508        assert_eq!(coerce_value(&json!(true), "integer"), CoerceResult::Failed);
509    }
510
511    #[test]
512    fn coerce_value_bool_to_number_fails() {
513        assert_eq!(coerce_value(&json!(true), "number"), CoerceResult::Failed);
514    }
515
516    #[test]
517    fn coerce_value_incompatible_fails() {
518        assert_eq!(coerce_value(&json!(null), "string"), CoerceResult::Failed);
519        assert_eq!(coerce_value(&json!([1]), "string"), CoerceResult::Failed);
520        assert_eq!(coerce_value(&json!("abc"), "integer"), CoerceResult::Failed);
521        assert_eq!(coerce_value(&json!("abc"), "number"), CoerceResult::Failed);
522        assert_eq!(coerce_value(&json!("maybe"), "boolean"), CoerceResult::Failed);
523    }
524
525    #[test]
526    fn coerce_value_double_and_float_aliases() {
527        assert_eq!(coerce_value(&json!(1.5), "double"), CoerceResult::AlreadyCorrect);
528        assert_eq!(coerce_value(&json!(1.5), "float"), CoerceResult::AlreadyCorrect);
529        match coerce_value(&json!("1.5"), "double") {
530            CoerceResult::Coerced(v, _) => assert_eq!(v.as_f64().unwrap(), 1.5),
531            other => panic!("expected Coerced, got {:?}", other),
532        }
533    }
534
535    #[test]
536    fn coerce_value_unknown_type_fails() {
537        assert_eq!(coerce_value(&json!("x"), "foobar"), CoerceResult::Failed);
538    }
539
540    #[test]
541    fn coerce_to_integer_from_float_number() {
542        assert_eq!(coerce_to_integer(&json!(3.0)), Some(3));
543        assert_eq!(coerce_to_integer(&json!(3.5)), None);
544    }
545}