Skip to main content

fakecloud_ec2/
service_helpers.rs

1//! Shared EC2 request-parsing and error helpers.
2//!
3//! EC2's query encoding uses 1-based indexed list members (`ResourceId.1`,
4//! `Tag.2.Key`) and a uniform `Filter.N.Name` / `Filter.N.Value.M` shape on
5//! every `Describe*` operation. These helpers parse those shapes once so every
6//! resource-family batch reuses them rather than re-deriving the indexing.
7
8use std::collections::HashMap;
9
10use http::StatusCode;
11
12use fakecloud_core::service::AwsServiceError;
13
14/// An EC2 `Filter.N` entry: a name and one or more accepted values (OR within a
15/// filter, AND across filters — AWS semantics).
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct Filter {
18    pub name: String,
19    pub values: Vec<String>,
20}
21
22/// Generate an EC2 resource id: `<prefix>-<17 lowercase hex>`, matching the
23/// modern long-id format (e.g. `vpc-0a1b2c3d4e5f67890`).
24pub fn gen_id(prefix: &str) -> String {
25    let hex = uuid::Uuid::new_v4().simple().to_string();
26    format!("{prefix}-{}", &hex[..17])
27}
28
29/// `InvalidParameterValue` — the catch-all 400 for bad EC2 input.
30pub fn invalid_parameter_value(message: impl Into<String>) -> AwsServiceError {
31    AwsServiceError::aws_error(
32        StatusCode::BAD_REQUEST,
33        "InvalidParameterValue",
34        message.into(),
35    )
36}
37
38/// `MissingParameter` — a required parameter was absent.
39pub fn missing_parameter(name: &str) -> AwsServiceError {
40    AwsServiceError::aws_error(
41        StatusCode::BAD_REQUEST,
42        "MissingParameter",
43        format!("The request must contain the parameter {name}"),
44    )
45}
46
47/// An EC2 `Invalid<Resource>.NotFound`-style error (HTTP 400, matching AWS).
48pub fn not_found(code: &str, id: &str) -> AwsServiceError {
49    AwsServiceError::aws_error(
50        StatusCode::BAD_REQUEST,
51        code,
52        format!("The ID '{id}' does not exist"),
53    )
54}
55
56/// `InvalidInstanceID.NotFound` (HTTP 400) — the requested instance does not
57/// exist, matching what AWS returns for state-change/describe ops on a bad id.
58pub fn instance_not_found(id: &str) -> AwsServiceError {
59    AwsServiceError::aws_error(
60        StatusCode::BAD_REQUEST,
61        "InvalidInstanceID.NotFound",
62        format!("The instance ID '{id}' does not exist"),
63    )
64}
65
66/// `InstanceLimitExceeded` (HTTP 400) — the requested instance count exceeds
67/// the limit, matching what AWS returns when MaxCount is above the per-request
68/// (or account) ceiling.
69pub fn instance_limit_exceeded(message: impl Into<String>) -> AwsServiceError {
70    AwsServiceError::aws_error(
71        StatusCode::BAD_REQUEST,
72        "InstanceLimitExceeded",
73        message.into(),
74    )
75}
76
77/// `IncorrectInstanceState` (HTTP 400) — an instance state-change is illegal
78/// from the instance's current state (e.g. starting a terminated instance).
79pub fn incorrect_instance_state(id: &str, current: &str) -> AwsServiceError {
80    AwsServiceError::aws_error(
81        StatusCode::BAD_REQUEST,
82        "IncorrectInstanceState",
83        format!("The instance '{id}' is not in a state from which it can be modified (current state: {current})"),
84    )
85}
86
87/// Match an EC2 filter value against a candidate, honoring the `*` (any run)
88/// and `?` (any single char) wildcards AWS supports in filter values. A value
89/// with no wildcard is an exact match.
90pub fn filter_value_matches(pattern: &str, candidate: &str) -> bool {
91    if !pattern.contains('*') && !pattern.contains('?') {
92        return pattern == candidate;
93    }
94    glob_match(pattern.as_bytes(), candidate.as_bytes())
95}
96
97/// Minimal glob matcher for `*`/`?` over bytes (EC2 filter wildcards).
98fn glob_match(pat: &[u8], text: &[u8]) -> bool {
99    let (mut p, mut t) = (0usize, 0usize);
100    let (mut star_p, mut star_t): (Option<usize>, usize) = (None, 0);
101    while t < text.len() {
102        if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
103            p += 1;
104            t += 1;
105        } else if p < pat.len() && pat[p] == b'*' {
106            star_p = Some(p);
107            star_t = t;
108            p += 1;
109        } else if let Some(sp) = star_p {
110            p = sp + 1;
111            star_t += 1;
112            t = star_t;
113        } else {
114            return false;
115        }
116    }
117    while p < pat.len() && pat[p] == b'*' {
118        p += 1;
119    }
120    p == pat.len()
121}
122
123/// Apply offset-based pagination to an already-sorted item list. Returns the
124/// page slice plus the opaque `NextToken` to return (the absolute next offset
125/// as a string), or `None` when the page reaches the end. `max_results` of
126/// `None` means "all remaining".
127pub fn paginate<T: Clone>(
128    items: &[T],
129    next_token: Option<&str>,
130    max_results: Option<usize>,
131) -> (Vec<T>, Option<String>) {
132    let start = next_token
133        .and_then(|t| t.parse::<usize>().ok())
134        .unwrap_or(0);
135    let start = start.min(items.len());
136    let end = match max_results {
137        Some(n) => (start + n).min(items.len()),
138        None => items.len(),
139    };
140    let page = items[start..end].to_vec();
141    let token = if end < items.len() {
142        Some(end.to_string())
143    } else {
144        None
145    };
146    (page, token)
147}
148
149/// Require a non-empty scalar parameter, else `MissingParameter`. Omitting a
150/// required scalar is wire-observable, so the conformance harness generates a
151/// negative variant for it — handlers must reject it.
152pub fn require(params: &HashMap<String, String>, key: &str) -> Result<String, AwsServiceError> {
153    params
154        .get(key)
155        .filter(|v| !v.is_empty())
156        .cloned()
157        .ok_or_else(|| missing_parameter(key))
158}
159
160/// Require a structure member to be present, identified by any wire param
161/// under `{prefix}.` (e.g. `InstanceTagAttribute.IncludeAllTagsOfInstance`).
162/// Omitting a required *structure* is wire-observable, so the harness emits a
163/// `negative_omit_<Struct>` variant — handlers must reject it.
164pub fn require_struct(
165    params: &HashMap<String, String>,
166    prefix: &str,
167) -> Result<(), AwsServiceError> {
168    let pat = format!("{prefix}.");
169    if params.keys().any(|k| k.starts_with(&pat)) {
170        Ok(())
171    } else {
172        Err(missing_parameter(prefix))
173    }
174}
175
176/// Reject a present-but-invalid enum value (the harness's
177/// `negative_invalid_enum_*` variant). Absent is allowed here — required-ness
178/// is enforced separately via [`require`].
179pub fn validate_enum(
180    params: &HashMap<String, String>,
181    key: &str,
182    allowed: &[&str],
183) -> Result<(), AwsServiceError> {
184    if let Some(v) = params.get(key).filter(|v| !v.is_empty()) {
185        if !allowed.contains(&v.as_str()) {
186            return Err(invalid_parameter_value(format!(
187                "Invalid value '{v}' for {key}"
188            )));
189        }
190    }
191    Ok(())
192}
193
194/// Reject an out-of-range `MaxResults` (the harness's `negative_below_min` /
195/// `negative_above_max` variants). EC2 describe pages bound MaxResults to
196/// [5, 1000] unless documented otherwise.
197pub fn validate_max_results(
198    params: &HashMap<String, String>,
199    min: i64,
200    max: i64,
201) -> Result<(), AwsServiceError> {
202    if let Some(v) = params.get("MaxResults").filter(|v| !v.is_empty()) {
203        if let Ok(n) = v.parse::<i64>() {
204            if n < min || n > max {
205                return Err(invalid_parameter_value(format!(
206                    "MaxResults must be between {min} and {max}"
207                )));
208            }
209        }
210    }
211    Ok(())
212}
213
214/// Reject a present integer parameter outside `[min, max]` (the harness's
215/// `negative_below_min_*` / `negative_above_max_*` variants for `@range`
216/// members like `PrivateIpAddressCount` or `MaxDrainDurationSeconds`).
217pub fn validate_int_range(
218    params: &HashMap<String, String>,
219    key: &str,
220    min: i64,
221    max: i64,
222) -> Result<(), AwsServiceError> {
223    if let Some(v) = params.get(key).filter(|v| !v.is_empty()) {
224        if let Ok(n) = v.parse::<i64>() {
225            if n < min || n > max {
226                return Err(invalid_parameter_value(format!(
227                    "{key} must be between {min} and {max}"
228                )));
229            }
230        }
231    }
232    Ok(())
233}
234
235/// Reject a present parameter whose length is outside `[min, max]` (the
236/// harness's `negative_too_short_*` / `negative_too_long_*` variants for
237/// `@length`-constrained members such as a bounded `NextToken`).
238pub fn validate_length(
239    params: &HashMap<String, String>,
240    key: &str,
241    min: usize,
242    max: usize,
243) -> Result<(), AwsServiceError> {
244    if let Some(v) = params.get(key) {
245        let n = v.chars().count();
246        if n < min || n > max {
247            return Err(invalid_parameter_value(format!(
248                "{key} length must be between {min} and {max}"
249            )));
250        }
251    }
252    Ok(())
253}
254
255/// Collect a 1-based indexed list, e.g. `ResourceId.1`, `ResourceId.2`, ….
256///
257/// EC2 list members are contiguous from index 1; collection stops at the first
258/// missing index. Empty values terminate the list too (matching how the SDKs
259/// never emit a gap).
260pub fn indexed_list(params: &HashMap<String, String>, prefix: &str) -> Vec<String> {
261    let mut out = Vec::new();
262    let mut i = 1usize;
263    loop {
264        let key = format!("{prefix}.{i}");
265        match params.get(&key) {
266            Some(v) if !v.is_empty() => out.push(v.clone()),
267            _ => break,
268        }
269        i += 1;
270    }
271    out
272}
273
274/// Parse `Filter.N.Name` + `Filter.N.Value.M` into [`Filter`] entries.
275pub fn parse_filters(params: &HashMap<String, String>) -> Vec<Filter> {
276    let mut out = Vec::new();
277    let mut i = 1usize;
278    loop {
279        let name_key = format!("Filter.{i}.Name");
280        let Some(name) = params.get(&name_key).filter(|v| !v.is_empty()) else {
281            break;
282        };
283        let values = indexed_list(params, &format!("Filter.{i}.Value"));
284        out.push(Filter {
285            name: name.clone(),
286            values,
287        });
288        i += 1;
289    }
290    out
291}
292
293/// Parse `{prefix}.N.Key` + `{prefix}.N.Value` tag pairs (the request shape for
294/// `CreateTags`/`DeleteTags` and `TagSpecification.N.Tag.M`).
295///
296/// The value is `None` only when the `Value` parameter is *absent* — for
297/// `DeleteTags` that means "remove this key regardless of value". A *present*
298/// `Value` (including an explicit empty string `Value=`) is preserved as
299/// `Some(value)` so DeleteTags can match the empty-value tag specifically
300/// rather than collapsing it into a key-only delete.
301pub fn parse_tag_pairs(
302    params: &HashMap<String, String>,
303    prefix: &str,
304) -> Vec<(String, Option<String>)> {
305    let mut out = Vec::new();
306    let mut i = 1usize;
307    loop {
308        let key_param = format!("{prefix}.{i}.Key");
309        let Some(key) = params.get(&key_param).filter(|v| !v.is_empty()) else {
310            break;
311        };
312        let value = params.get(&format!("{prefix}.{i}.Value")).cloned();
313        out.push((key.clone(), value));
314        i += 1;
315    }
316    out
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn p(pairs: &[(&str, &str)]) -> HashMap<String, String> {
324        pairs
325            .iter()
326            .map(|(k, v)| (k.to_string(), v.to_string()))
327            .collect()
328    }
329
330    #[test]
331    fn indexed_list_collects_contiguous_then_stops() {
332        let params = p(&[("ResourceId.1", "vpc-1"), ("ResourceId.2", "vpc-2")]);
333        assert_eq!(indexed_list(&params, "ResourceId"), vec!["vpc-1", "vpc-2"]);
334    }
335
336    #[test]
337    fn indexed_list_stops_at_gap() {
338        let params = p(&[("ResourceId.1", "vpc-1"), ("ResourceId.3", "vpc-3")]);
339        assert_eq!(indexed_list(&params, "ResourceId"), vec!["vpc-1"]);
340    }
341
342    #[test]
343    fn parse_filters_groups_name_and_values() {
344        let params = p(&[
345            ("Filter.1.Name", "resource-id"),
346            ("Filter.1.Value.1", "vpc-1"),
347            ("Filter.1.Value.2", "vpc-2"),
348            ("Filter.2.Name", "key"),
349            ("Filter.2.Value.1", "Name"),
350        ]);
351        let filters = parse_filters(&params);
352        assert_eq!(filters.len(), 2);
353        assert_eq!(
354            filters[0],
355            Filter {
356                name: "resource-id".into(),
357                values: vec!["vpc-1".into(), "vpc-2".into()]
358            }
359        );
360        assert_eq!(
361            filters[1],
362            Filter {
363                name: "key".into(),
364                values: vec!["Name".into()]
365            }
366        );
367    }
368
369    #[test]
370    fn parse_tag_pairs_handles_optional_value() {
371        let params = p(&[
372            ("Tag.1.Key", "Name"),
373            ("Tag.1.Value", "web"),
374            ("Tag.2.Key", "env"),
375        ]);
376        let tags = parse_tag_pairs(&params, "Tag");
377        assert_eq!(
378            tags,
379            vec![("Name".into(), Some("web".into())), ("env".into(), None)]
380        );
381    }
382
383    #[test]
384    fn filter_wildcards() {
385        assert!(filter_value_matches("web", "web"));
386        assert!(!filter_value_matches("web", "web1"));
387        assert!(filter_value_matches("web*", "web-prod"));
388        assert!(filter_value_matches("*prod", "web-prod"));
389        assert!(filter_value_matches("web*prod", "web-staging-prod"));
390        assert!(filter_value_matches("we?", "web"));
391        assert!(!filter_value_matches("we?", "web1"));
392        assert!(filter_value_matches("*", "anything"));
393        assert!(!filter_value_matches("web?", "web"));
394    }
395
396    #[test]
397    fn paginate_pages_and_round_trips_token() {
398        let items: Vec<i32> = (0..10).collect();
399        let (page, token) = paginate(&items, None, Some(4));
400        assert_eq!(page, vec![0, 1, 2, 3]);
401        assert_eq!(token.as_deref(), Some("4"));
402        let (page2, token2) = paginate(&items, token.as_deref(), Some(4));
403        assert_eq!(page2, vec![4, 5, 6, 7]);
404        assert_eq!(token2.as_deref(), Some("8"));
405        let (page3, token3) = paginate(&items, token2.as_deref(), Some(4));
406        assert_eq!(page3, vec![8, 9]);
407        assert_eq!(token3, None);
408    }
409
410    #[test]
411    fn paginate_no_max_returns_all() {
412        let items: Vec<i32> = (0..3).collect();
413        let (page, token) = paginate(&items, None, None);
414        assert_eq!(page, items);
415        assert_eq!(token, None);
416    }
417
418    #[test]
419    fn parse_tag_pairs_distinguishes_empty_value_from_absent() {
420        // Present-but-empty `Value=` -> Some(""), absent `Value` -> None.
421        // DeleteTags relies on this: `Value=` deletes only the empty-value tag,
422        // while an absent value deletes the key regardless of value.
423        let params = p(&[("Tag.1.Key", "a"), ("Tag.1.Value", ""), ("Tag.2.Key", "b")]);
424        let tags = parse_tag_pairs(&params, "Tag");
425        assert_eq!(
426            tags,
427            vec![("a".into(), Some("".into())), ("b".into(), None)]
428        );
429    }
430}