From 738641342aae6b5a9f47004fbc1baab5843feece Mon Sep 17 00:00:00 2001 From: ashutosh0x Date: Wed, 4 Feb 2026 14:13:44 +0530 Subject: [PATCH] Fix #3991: Quote number-like strings in CloudFormation templates This fixes a long-standing issue where strings like '1e10' or '0755' were being output without quotes by 'aws cloudformation package', causing them to be misinterpreted as numbers when re-parsed. Added a custom string representer to yaml_dump that identifies and quotes ambiguous strings (scientific notation, octal, hex, binary, and sexagesimal formats). --- .../cloudformation/yamlhelper.py | 58 ++++++++++++ .../cloudformation/test_yamlhelper.py | 89 +++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/awscli/customizations/cloudformation/yamlhelper.py b/awscli/customizations/cloudformation/yamlhelper.py index 61603603e669..6b7686720147 100644 --- a/awscli/customizations/cloudformation/yamlhelper.py +++ b/awscli/customizations/cloudformation/yamlhelper.py @@ -58,6 +58,63 @@ def _dict_representer(dumper, data): return dumper.represent_dict(data.items()) +def _needs_quoting(value): + """ + Check if a string value needs to be quoted to prevent YAML from + interpreting it as a non-string type (number, boolean, null, etc.). + + This addresses issue #3991 where strings like '1e10' were being + output without quotes, causing them to be interpreted as numbers + when the YAML is re-parsed. + """ + if not isinstance(value, str) or not value: + return False + + # Check for scientific notation (e.g., 1e10, 1E-5, 2.5e+3) + # These are valid floats but should remain as strings if originally strings + import re + scientific_pattern = r'^[+-]?(\d+\.?\d*|\d*\.?\d+)[eE][+-]?\d+$' + if re.match(scientific_pattern, value): + return True + + # Check for octal notation (e.g., 0o755, 0O644) + if re.match(r'^0[oO][0-7]+$', value): + return True + + # Check for hex notation (e.g., 0x1A, 0X2B) + if re.match(r'^0[xX][0-9a-fA-F]+$', value): + return True + + # Check for binary notation (e.g., 0b1010) + if re.match(r'^0[bB][01]+$', value): + return True + + # Check for special YAML float values + if value.lower() in ('.inf', '-.inf', '.nan', '+.inf'): + return True + + # Check for YAML 1.1 legacy octals (e.g., 0755) - numbers starting with 0 + # but not just "0" and containing only digits + if re.match(r'^0\d+$', value): + return True + + # Check for sexagesimal (base 60) numbers like 1:30:00 + if re.match(r'^\d+:\d+(:\d+)*$', value): + return True + + return False + + +def _string_representer(dumper, data): + """ + Custom string representer that quotes strings which could be + misinterpreted as numbers or other YAML types. + """ + if _needs_quoting(data): + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style="'") + return dumper.represent_scalar('tag:yaml.org,2002:str', data) + + def yaml_dump(dict_to_dump): """ Dumps the dictionary as a YAML document @@ -65,6 +122,7 @@ def yaml_dump(dict_to_dump): :return: """ FlattenAliasDumper.add_representer(OrderedDict, _dict_representer) + FlattenAliasDumper.add_representer(str, _string_representer) return yaml.dump( dict_to_dump, default_flow_style=False, diff --git a/tests/unit/customizations/cloudformation/test_yamlhelper.py b/tests/unit/customizations/cloudformation/test_yamlhelper.py index 7a5d21bae00f..2ad1eedf7e14 100644 --- a/tests/unit/customizations/cloudformation/test_yamlhelper.py +++ b/tests/unit/customizations/cloudformation/test_yamlhelper.py @@ -182,3 +182,92 @@ def test_unroll_yaml_anchors(self): ) actual = yaml_dump(template) self.assertEqual(actual, expected) + + def test_scientific_notation_strings_are_quoted(self): + """ + Test fix for issue #3991: strings that look like scientific notation + should be quoted to prevent them from being interpreted as numbers. + """ + template = { + "Parameters": { + "Value1": {"Default": "1e10"}, + "Value2": {"Default": "1E-5"}, + "Value3": {"Default": "2.5e+3"}, + } + } + dumped = yaml_dump(template) + + # Scientific notation strings should be quoted + self.assertIn("'1e10'", dumped) + self.assertIn("'1E-5'", dumped) + self.assertIn("'2.5e+3'", dumped) + + # Verify round-trip preserves string type + reparsed = yaml_parse(dumped) + self.assertEqual(reparsed["Parameters"]["Value1"]["Default"], "1e10") + self.assertEqual(reparsed["Parameters"]["Value2"]["Default"], "1E-5") + self.assertEqual(reparsed["Parameters"]["Value3"]["Default"], "2.5e+3") + + def test_octal_hex_binary_strings_are_quoted(self): + """ + Test that octal, hex, and binary notation strings are quoted. + """ + template = { + "Values": { + "Octal": "0755", + "OctalNew": "0o755", + "Hex": "0x1A2B", + "Binary": "0b1010", + } + } + dumped = yaml_dump(template) + + # These should be quoted + self.assertIn("'0755'", dumped) + self.assertIn("'0o755'", dumped) + self.assertIn("'0x1A2B'", dumped) + self.assertIn("'0b1010'", dumped) + + # Verify round-trip + reparsed = yaml_parse(dumped) + self.assertEqual(reparsed["Values"]["Octal"], "0755") + self.assertEqual(reparsed["Values"]["Hex"], "0x1A2B") + + def test_sexagesimal_strings_are_quoted(self): + """ + Test that sexagesimal (base 60) notation strings are quoted. + """ + template = { + "Values": { + "Time1": "1:30:00", + "Time2": "12:30", + } + } + dumped = yaml_dump(template) + + # Should be quoted + self.assertIn("'1:30:00'", dumped) + self.assertIn("'12:30'", dumped) + + # Verify round-trip + reparsed = yaml_parse(dumped) + self.assertEqual(reparsed["Values"]["Time1"], "1:30:00") + self.assertEqual(reparsed["Values"]["Time2"], "12:30") + + def test_normal_strings_not_excessively_quoted(self): + """ + Test that normal strings are not unnecessarily quoted. + """ + template = { + "Values": { + "Normal1": "hello", + "Normal2": "world-123", + "Arn": "arn:aws:s3:::bucket", + } + } + dumped = yaml_dump(template) + + # Normal strings should not be quoted (except ARN which has colons) + self.assertIn("Normal1: hello", dumped) + self.assertIn("Normal2: world-123", dumped) +