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. A present-but-empty value terminates the list too — for
259/// id-lists (`InstanceId.N`, `GroupId.N`, `VolumeId.N`, …) an empty member is
260/// meaningless, and treating it as a terminator matches how the SDKs never emit
261/// a gap. Filter *values* differ (an empty value is a legitimate member); use
262/// [`indexed_list_keep_empty`] there.
263pub fn indexed_list(params: &HashMap<String, String>, prefix: &str) -> Vec<String> {
264    let mut out = Vec::new();
265    let mut i = 1usize;
266    loop {
267        let key = format!("{prefix}.{i}");
268        match params.get(&key) {
269            Some(v) if !v.is_empty() => out.push(v.clone()),
270            _ => break,
271        }
272        i += 1;
273    }
274    out
275}
276
277/// Like [`indexed_list`], but a present-but-empty value (`Filter.1.Value.1=`)
278/// is preserved as a legitimate empty-string member rather than terminating the
279/// list — the terminator is "next index absent", not "value empty". Used for
280/// filter values, where filtering for an absent/empty tag value is valid and an
281/// empty value must NOT truncate (or self-referentially loop) the list.
282pub fn indexed_list_keep_empty(params: &HashMap<String, String>, prefix: &str) -> Vec<String> {
283    let mut out = Vec::new();
284    let mut i = 1usize;
285    loop {
286        let key = format!("{prefix}.{i}");
287        match params.get(&key) {
288            Some(v) => out.push(v.clone()),
289            None => break,
290        }
291        i += 1;
292    }
293    out
294}
295
296/// Parse `Filter.N.Name` + `Filter.N.Value.M` into [`Filter`] entries.
297pub fn parse_filters(params: &HashMap<String, String>) -> Vec<Filter> {
298    let mut out = Vec::new();
299    let mut i = 1usize;
300    loop {
301        let name_key = format!("Filter.{i}.Name");
302        let Some(name) = params.get(&name_key).filter(|v| !v.is_empty()) else {
303            break;
304        };
305        let values = indexed_list_keep_empty(params, &format!("Filter.{i}.Value"));
306        out.push(Filter {
307            name: name.clone(),
308            values,
309        });
310        i += 1;
311    }
312    out
313}
314
315/// Parse `{prefix}.N.Key` + `{prefix}.N.Value` tag pairs (the request shape for
316/// `CreateTags`/`DeleteTags` and `TagSpecification.N.Tag.M`).
317///
318/// The value is `None` only when the `Value` parameter is *absent* — for
319/// `DeleteTags` that means "remove this key regardless of value". A *present*
320/// `Value` (including an explicit empty string `Value=`) is preserved as
321/// `Some(value)` so DeleteTags can match the empty-value tag specifically
322/// rather than collapsing it into a key-only delete.
323pub fn parse_tag_pairs(
324    params: &HashMap<String, String>,
325    prefix: &str,
326) -> Vec<(String, Option<String>)> {
327    let mut out = Vec::new();
328    let mut i = 1usize;
329    loop {
330        let key_param = format!("{prefix}.{i}.Key");
331        let Some(key) = params.get(&key_param).filter(|v| !v.is_empty()) else {
332            break;
333        };
334        let value = params.get(&format!("{prefix}.{i}.Value")).cloned();
335        out.push((key.clone(), value));
336        i += 1;
337    }
338    out
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn p(pairs: &[(&str, &str)]) -> HashMap<String, String> {
346        pairs
347            .iter()
348            .map(|(k, v)| (k.to_string(), v.to_string()))
349            .collect()
350    }
351
352    #[test]
353    fn indexed_list_collects_contiguous_then_stops() {
354        let params = p(&[("ResourceId.1", "vpc-1"), ("ResourceId.2", "vpc-2")]);
355        assert_eq!(indexed_list(&params, "ResourceId"), vec!["vpc-1", "vpc-2"]);
356    }
357
358    #[test]
359    fn indexed_list_stops_at_gap() {
360        let params = p(&[("ResourceId.1", "vpc-1"), ("ResourceId.3", "vpc-3")]);
361        assert_eq!(indexed_list(&params, "ResourceId"), vec!["vpc-1"]);
362    }
363
364    #[test]
365    fn indexed_list_empty_value_terminates_for_id_lists() {
366        // Plain id-lists keep the original semantics: a present-but-empty member
367        // is meaningless and terminates (SDKs never emit an empty id member).
368        let params = p(&[("InstanceId.1", ""), ("InstanceId.2", "i-2")]);
369        assert_eq!(indexed_list(&params, "InstanceId"), Vec::<String>::new());
370    }
371
372    #[test]
373    fn indexed_list_keep_empty_preserves_present_but_empty_value() {
374        // `Filter.1.Value.1=` (explicit empty string) is a legitimate member,
375        // not a terminator: it must be kept, and a following contiguous index
376        // must still be collected rather than truncated at the empty one.
377        let params = p(&[("Value.1", ""), ("Value.2", "x")]);
378        assert_eq!(indexed_list_keep_empty(&params, "Value"), vec!["", "x"]);
379    }
380
381    #[test]
382    fn indexed_list_keep_empty_then_absent_stops() {
383        // A trailing empty value followed by an absent index still terminates
384        // (no infinite loop on a genuinely-absent index).
385        let params = p(&[("Value.1", "")]);
386        assert_eq!(indexed_list_keep_empty(&params, "Value"), vec![""]);
387    }
388
389    #[test]
390    fn parse_filters_keeps_empty_filter_value() {
391        // Filtering for an empty/absent tag value: `Filter.1.Value.1=` must be
392        // preserved as a single empty-string value, not dropped.
393        let params = p(&[("Filter.1.Name", "tag:env"), ("Filter.1.Value.1", "")]);
394        let filters = parse_filters(&params);
395        assert_eq!(
396            filters,
397            vec![Filter {
398                name: "tag:env".into(),
399                values: vec!["".into()]
400            }]
401        );
402    }
403
404    #[test]
405    fn parse_filters_groups_name_and_values() {
406        let params = p(&[
407            ("Filter.1.Name", "resource-id"),
408            ("Filter.1.Value.1", "vpc-1"),
409            ("Filter.1.Value.2", "vpc-2"),
410            ("Filter.2.Name", "key"),
411            ("Filter.2.Value.1", "Name"),
412        ]);
413        let filters = parse_filters(&params);
414        assert_eq!(filters.len(), 2);
415        assert_eq!(
416            filters[0],
417            Filter {
418                name: "resource-id".into(),
419                values: vec!["vpc-1".into(), "vpc-2".into()]
420            }
421        );
422        assert_eq!(
423            filters[1],
424            Filter {
425                name: "key".into(),
426                values: vec!["Name".into()]
427            }
428        );
429    }
430
431    #[test]
432    fn parse_tag_pairs_handles_optional_value() {
433        let params = p(&[
434            ("Tag.1.Key", "Name"),
435            ("Tag.1.Value", "web"),
436            ("Tag.2.Key", "env"),
437        ]);
438        let tags = parse_tag_pairs(&params, "Tag");
439        assert_eq!(
440            tags,
441            vec![("Name".into(), Some("web".into())), ("env".into(), None)]
442        );
443    }
444
445    #[test]
446    fn filter_wildcards() {
447        assert!(filter_value_matches("web", "web"));
448        assert!(!filter_value_matches("web", "web1"));
449        assert!(filter_value_matches("web*", "web-prod"));
450        assert!(filter_value_matches("*prod", "web-prod"));
451        assert!(filter_value_matches("web*prod", "web-staging-prod"));
452        assert!(filter_value_matches("we?", "web"));
453        assert!(!filter_value_matches("we?", "web1"));
454        assert!(filter_value_matches("*", "anything"));
455        assert!(!filter_value_matches("web?", "web"));
456    }
457
458    #[test]
459    fn paginate_pages_and_round_trips_token() {
460        let items: Vec<i32> = (0..10).collect();
461        let (page, token) = paginate(&items, None, Some(4));
462        assert_eq!(page, vec![0, 1, 2, 3]);
463        assert_eq!(token.as_deref(), Some("4"));
464        let (page2, token2) = paginate(&items, token.as_deref(), Some(4));
465        assert_eq!(page2, vec![4, 5, 6, 7]);
466        assert_eq!(token2.as_deref(), Some("8"));
467        let (page3, token3) = paginate(&items, token2.as_deref(), Some(4));
468        assert_eq!(page3, vec![8, 9]);
469        assert_eq!(token3, None);
470    }
471
472    #[test]
473    fn paginate_no_max_returns_all() {
474        let items: Vec<i32> = (0..3).collect();
475        let (page, token) = paginate(&items, None, None);
476        assert_eq!(page, items);
477        assert_eq!(token, None);
478    }
479
480    #[test]
481    fn parse_tag_pairs_distinguishes_empty_value_from_absent() {
482        // Present-but-empty `Value=` -> Some(""), absent `Value` -> None.
483        // DeleteTags relies on this: `Value=` deletes only the empty-value tag,
484        // while an absent value deletes the key regardless of value.
485        let params = p(&[("Tag.1.Key", "a"), ("Tag.1.Value", ""), ("Tag.2.Key", "b")]);
486        let tags = parse_tag_pairs(&params, "Tag");
487        assert_eq!(
488            tags,
489            vec![("a".into(), Some("".into())), ("b".into(), None)]
490        );
491    }
492}