Skip to main content

fakecloud_aws/
ec2query.rs

1//! Serialization helpers for the AWS `ec2Query` protocol.
2//!
3//! EC2 uses a variant of the AWS Query protocol whose XML responses differ
4//! from `awsQuery` (SQS/SNS/RDS/IAM) in three load-bearing ways:
5//!
6//! 1. **No `<Result>` wrapper.** An `awsQuery` response nests the body under
7//!    `<{Action}Result>`. EC2 places the result members directly inside
8//!    `<{Action}Response>`, preceded by a lowercase `<requestId>` element
9//!    (NOT the `<ResponseMetadata><RequestId>` block awsQuery uses).
10//! 2. **Flattened lists.** Lists serialize as a wrapper element whose repeated
11//!    children are literally named `<item>` — e.g.
12//!    `<instancesSet><item>…</item><item>…</item></instancesSet>` — with no
13//!    intermediate `<member>` tag.
14//! 3. **lowerCamelCase tags.** Response element names are lower-camel
15//!    (`<reservationId>`, `<tagSet>`, `<groupSet>`), unlike the UpperCamel
16//!    member names awsQuery echoes.
17//!
18//! The real AWS SDKs deserialize strictly against these shapes, so the unit
19//! tests below round-trip the rendered XML through a minimal parser to lock the
20//! structure in. See [`crate::xml::xml_escape`] for content escaping.
21
22use crate::xml::xml_escape;
23
24/// The EC2 response namespace. Note the trailing slash — EC2 emits it on the
25/// wire even though the Smithy model's `xmlNamespace` uri omits it.
26pub const EC2_NAMESPACE: &str = "http://ec2.amazonaws.com/doc/2016-11-15/";
27
28/// Wrap an operation's result `body` in the ec2Query response envelope.
29///
30/// ```xml
31/// <?xml version="1.0" encoding="UTF-8"?>
32/// <RunInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
33///   <requestId>{request_id}</requestId>
34///   {body}
35/// </RunInstancesResponse>
36/// ```
37///
38/// `body` is the already-rendered, already-escaped inner XML (use the element
39/// and list helpers in this module to build it).
40pub fn ec2_response(action: &str, request_id: &str, body: &str) -> String {
41    format!(
42        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
43         <{action}Response xmlns=\"{EC2_NAMESPACE}\">\
44         <requestId>{rid}</requestId>{body}</{action}Response>",
45        rid = xml_escape(request_id),
46    )
47}
48
49/// Render a leaf element with escaped text content: `<name>value</name>`.
50pub fn ec2_elem(name: &str, value: &str) -> String {
51    format!("<{name}>{}</{name}>", xml_escape(value))
52}
53
54/// Render an optional leaf element. Returns the empty string when `value` is
55/// `None` (EC2 omits absent optional members rather than emitting empty tags).
56pub fn ec2_elem_opt(name: &str, value: Option<&str>) -> String {
57    match value {
58        Some(v) => ec2_elem(name, v),
59        None => String::new(),
60    }
61}
62
63/// Render a boolean as EC2's lowercase `true`/`false`: `<name>true</name>`.
64pub fn ec2_bool(name: &str, value: bool) -> String {
65    format!("<{name}>{value}</{name}>")
66}
67
68/// Render the canonical `<return>true</return>` body emitted by EC2's many
69/// mutate-and-acknowledge operations (DeleteVpc, AuthorizeSecurityGroupIngress,
70/// …). Pass to [`ec2_response`] as the body.
71pub fn ec2_return(value: bool) -> String {
72    format!("<return>{value}</return>")
73}
74
75/// Render a flattened EC2 list. `wrapper` is the set element name
76/// (e.g. `instancesSet`); each entry in `items` is the already-rendered inner
77/// XML of one `<item>` (do NOT pre-wrap entries in `<item>`).
78///
79/// An empty list still emits the wrapper element (`<wrapper/>`-equivalent),
80/// matching how the AWS SDKs expect an empty set to deserialize to `[]`.
81pub fn ec2_list(wrapper: &str, items: &[String]) -> String {
82    if items.is_empty() {
83        return format!("<{wrapper}/>");
84    }
85    let mut out = String::with_capacity(items.len() * 16 + wrapper.len() * 2 + 5);
86    out.push('<');
87    out.push_str(wrapper);
88    out.push('>');
89    for item in items {
90        out.push_str("<item>");
91        out.push_str(item);
92        out.push_str("</item>");
93    }
94    out.push_str("</");
95    out.push_str(wrapper);
96    out.push('>');
97    out
98}
99
100/// Render a flattened list of scalar leaf values, e.g. a `<groupSet>` of bare
101/// strings: `<groupSet><item>sg-1</item><item>sg-2</item></groupSet>`.
102pub fn ec2_scalar_list(wrapper: &str, values: &[String]) -> String {
103    let items: Vec<String> = values.iter().map(|v| xml_escape(v)).collect();
104    ec2_list(wrapper, &items)
105}
106
107/// Render an EC2 `<tagSet>` from `(key, value)` pairs. Each tag becomes
108/// `<item><key>…</key><value>…</value></item>`. Emits `<tagSet/>` when empty.
109pub fn ec2_tag_set(tags: &[(String, String)]) -> String {
110    let items: Vec<String> = tags
111        .iter()
112        .map(|(k, v)| format!("{}{}", ec2_elem("key", k), ec2_elem("value", v)))
113        .collect();
114    ec2_list("tagSet", &items)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    /// Minimal, dependency-free check that a rendered fragment contains a
122    /// well-formed, correctly-nested element. Good enough to assert the
123    /// structural invariants the AWS SDK deserializer relies on.
124    fn contains_balanced(haystack: &str, tag: &str) -> bool {
125        haystack.contains(&format!("<{tag}>")) && haystack.contains(&format!("</{tag}>"))
126    }
127
128    #[test]
129    fn response_envelope_has_no_result_wrapper_and_lowercase_request_id() {
130        let xml = ec2_response("DeleteVpc", "req-123", &ec2_return(true));
131        assert!(xml.starts_with("<?xml"));
132        assert!(
133            xml.contains("<DeleteVpcResponse xmlns=\"http://ec2.amazonaws.com/doc/2016-11-15/\">")
134        );
135        // ec2Query: lowercase <requestId>, NOT <ResponseMetadata><RequestId>.
136        assert!(xml.contains("<requestId>req-123</requestId>"));
137        assert!(!xml.contains("ResponseMetadata"));
138        assert!(!xml.contains("Result>"));
139        assert!(xml.contains("<return>true</return>"));
140        assert!(xml.ends_with("</DeleteVpcResponse>"));
141    }
142
143    #[test]
144    fn list_flattens_with_item_children_no_member_wrapper() {
145        let items = vec![
146            "<instanceId>i-1</instanceId>".to_string(),
147            "<instanceId>i-2</instanceId>".to_string(),
148        ];
149        let xml = ec2_list("instancesSet", &items);
150        assert_eq!(
151            xml,
152            "<instancesSet><item><instanceId>i-1</instanceId></item>\
153             <item><instanceId>i-2</instanceId></item></instancesSet>"
154        );
155        assert!(!xml.contains("<member>"));
156    }
157
158    #[test]
159    fn empty_list_emits_self_closing_wrapper() {
160        assert_eq!(ec2_list("instancesSet", &[]), "<instancesSet/>");
161        assert_eq!(ec2_scalar_list("groupSet", &[]), "<groupSet/>");
162        assert_eq!(ec2_tag_set(&[]), "<tagSet/>");
163    }
164
165    #[test]
166    fn scalar_list_escapes_and_wraps_each_value() {
167        let xml = ec2_scalar_list("groupSet", &["sg-1".to_string(), "a&b".to_string()]);
168        assert_eq!(
169            xml,
170            "<groupSet><item>sg-1</item><item>a&amp;b</item></groupSet>"
171        );
172    }
173
174    #[test]
175    fn tag_set_renders_key_value_items() {
176        let tags = vec![
177            ("Name".to_string(), "web".to_string()),
178            ("env".to_string(), "prod & test".to_string()),
179        ];
180        let xml = ec2_tag_set(&tags);
181        assert!(contains_balanced(&xml, "tagSet"));
182        assert!(xml.contains("<item><key>Name</key><value>web</value></item>"));
183        // content is escaped
184        assert!(xml.contains("<value>prod &amp; test</value>"));
185    }
186
187    #[test]
188    fn elem_helpers_escape_and_omit() {
189        assert_eq!(
190            ec2_elem("imageId", "ami-<1>"),
191            "<imageId>ami-&lt;1&gt;</imageId>"
192        );
193        assert_eq!(ec2_elem_opt("publicIp", None), "");
194        assert_eq!(
195            ec2_elem_opt("publicIp", Some("1.2.3.4")),
196            "<publicIp>1.2.3.4</publicIp>"
197        );
198        assert_eq!(
199            ec2_bool("ebsOptimized", false),
200            "<ebsOptimized>false</ebsOptimized>"
201        );
202    }
203
204    #[test]
205    fn nested_reservation_shape_round_trips_structurally() {
206        // The deepest real shape: reservationSet > item > instancesSet > item.
207        let instance = format!(
208            "{}{}{}",
209            ec2_elem("instanceId", "i-abc"),
210            ec2_elem("imageId", "ami-1"),
211            ec2_tag_set(&[("Name".to_string(), "x".to_string())]),
212        );
213        let instances = ec2_list("instancesSet", &[instance]);
214        let reservation = format!("{}{}", ec2_elem("reservationId", "r-1"), instances);
215        let body = ec2_list("reservationSet", &[reservation]);
216        let xml = ec2_response("DescribeInstances", "r-9", &body);
217        assert!(contains_balanced(&xml, "reservationSet"));
218        assert!(contains_balanced(&xml, "instancesSet"));
219        assert!(xml.contains("<reservationId>r-1</reservationId>"));
220        assert!(xml.contains("<instanceId>i-abc</instanceId>"));
221        // three <item> opens: 1 reservation + 1 instance + 1 tag
222        assert_eq!(xml.matches("<item>").count(), 3);
223    }
224}