1use diagnostics::{DetailLevel, Summary, ValidationReport, output};
2use rules::Severity;
3use schema_validator::{PropertyValueType, ResourceSchemaMetadata, SchemaValidator};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::sync::LazyLock;
7use template_model::compile_pattern;
8
9use crate::{ValidateConfig, ValidationEngine, ValidationError, validate_bytes_with_path};
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
18#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")]
19pub enum AwsCliValue {
20 Null,
21 Boolean { value: bool },
22 Integer { value: i64 },
23 UnsignedInteger { value: u64 },
24 Number { value: f64 },
25 String { value: String },
26 Bytes { value: Vec<u8> },
27 Array { items: Vec<AwsCliValue> },
28 Object { entries: HashMap<String, AwsCliValue> },
29 Unsupported { type_name: String },
30}
31
32impl AwsCliValue {
33 pub fn from_json(value: serde_json::Value) -> Self {
35 match value {
36 serde_json::Value::Null => Self::Null,
37 serde_json::Value::Bool(value) => Self::Boolean { value },
38 serde_json::Value::Number(value) => {
39 if let Some(value) = value.as_i64() {
40 Self::Integer { value }
41 } else if let Some(value) = value.as_u64() {
42 Self::UnsignedInteger { value }
43 } else if let Some(value) = value.as_f64() {
44 Self::Number { value }
45 } else {
46 Self::Unsupported { type_name: "JSON number".into() }
47 }
48 }
49 serde_json::Value::String(value) => Self::String { value },
50 serde_json::Value::Array(items) => Self::Array { items: items.into_iter().map(Self::from_json).collect() },
51 serde_json::Value::Object(entries) => Self::Object {
52 entries: entries.into_iter().map(|(key, value)| (key, Self::from_json(value))).collect(),
53 },
54 }
55 }
56
57 pub fn to_json(&self) -> Result<serde_json::Value, String> {
59 self.json_value().ok_or_else(|| match self {
60 Self::Bytes { .. } => "byte strings are not JSON values".to_string(),
61 Self::Number { .. } => "non-finite numbers are not JSON values".to_string(),
62 Self::Unsupported { type_name } => format!("{type_name} is not a supported request value"),
63 _ => "a nested request value is not JSON-compatible".to_string(),
64 })
65 }
66
67 fn json_value(&self) -> Option<serde_json::Value> {
68 match self {
69 Self::Null => Some(serde_json::Value::Null),
70 Self::Boolean { value } => Some(serde_json::Value::Bool(*value)),
71 Self::Integer { value } => Some(serde_json::json!(value)),
72 Self::UnsignedInteger { value } => Some(serde_json::json!(value)),
73 Self::Number { value } => serde_json::Number::from_f64(*value).map(serde_json::Value::Number),
74 Self::String { value } => Some(serde_json::Value::String(value.clone())),
75 Self::Bytes { .. } | Self::Unsupported { .. } => None,
76 Self::Array { items } => {
77 items.iter().map(Self::json_value).collect::<Option<Vec<_>>>().map(serde_json::Value::Array)
78 }
79 Self::Object { entries } => entries
80 .iter()
81 .map(|(key, value)| value.json_value().map(|value| (key.clone(), value)))
82 .collect::<Option<serde_json::Map<_, _>>>()
83 .map(serde_json::Value::Object),
84 }
85 }
86}
87
88impl From<serde_json::Value> for AwsCliValue {
89 fn from(value: serde_json::Value) -> Self {
90 Self::from_json(value)
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
98#[serde(rename_all = "camelCase")]
99pub struct AwsCliCommandContext {
100 pub service_name: String,
101 pub operation_name: String,
102 pub parameters: HashMap<String, AwsCliValue>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
105 pub service_prefix: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
108 pub http_method: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
111 pub is_read_only: Option<bool>,
112}
113
114pub type AwsCliCommand = AwsCliCommandContext;
119
120impl AwsCliCommandContext {
121 pub fn new(
122 service_name: impl Into<String>,
123 operation_name: impl Into<String>,
124 parameters: impl IntoIterator<Item = (String, AwsCliValue)>,
125 ) -> Self {
126 Self {
127 service_name: service_name.into(),
128 operation_name: operation_name.into(),
129 parameters: parameters.into_iter().collect(),
130 service_prefix: None,
131 http_method: None,
132 is_read_only: None,
133 }
134 }
135
136 pub fn with_service_prefix(mut self, service_prefix: impl Into<String>) -> Self {
137 self.service_prefix = Some(service_prefix.into());
138 self
139 }
140
141 pub fn with_http_method(mut self, http_method: impl Into<String>) -> Self {
142 self.http_method = Some(http_method.into());
143 self
144 }
145
146 pub fn with_read_only(mut self, is_read_only: bool) -> Self {
147 self.is_read_only = Some(is_read_only);
148 self
149 }
150
151 fn default_file_path(&self) -> String {
152 format!("aws-cli://{}/{}", self.service_name, self.operation_name)
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
159#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
160pub enum AwsCliOperationKind {
161 ReadOnly,
162 CloudFormationCreate,
163 CloudFormationUpdate,
164 CloudFormationDelete,
165 DataPlaneMutation,
166 UnmappedMutation,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
172#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
173pub enum AwsCliCommandValidationStatus {
174 Validated,
175 Skipped,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
181#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
182pub enum AwsCliTemplateSource {
183 TemplateBody,
184 CloudControlDesiredState,
185 SynthesizedCreate,
186 SynthesizedUpdate,
187}
188
189#[derive(Debug, Clone, Serialize)]
195#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
196#[serde(rename_all = "camelCase")]
197#[must_use]
198pub struct AwsCliCommandValidation {
199 pub operation_kind: AwsCliOperationKind,
200 pub status: AwsCliCommandValidationStatus,
201 pub template_source: Option<AwsCliTemplateSource>,
202 pub resource_types: Vec<String>,
203 pub reason: String,
204 pub report: Option<output::ValidationReport>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
211 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
212 pub template: Option<Vec<u8>>,
213}
214
215pub fn validate_aws_cli_command(
220 engine: &dyn ValidationEngine,
221 schema_validator: &SchemaValidator,
222 request: &AwsCliCommand,
223) -> Result<AwsCliCommandValidation, ValidationError> {
224 validate_aws_cli_command_with_path(engine, schema_validator, request, request.default_file_path())
225}
226
227pub fn validate_aws_cli_command_with_path(
230 engine: &dyn ValidationEngine,
231 schema_validator: &SchemaValidator,
232 request: &AwsCliCommand,
233 file_path: String,
234) -> Result<AwsCliCommandValidation, ValidationError> {
235 let classification = classify_operation(request, schema_validator)?;
236 let synthesis = synthesize_request(request, &classification, schema_validator)?;
237 let Some(template) = synthesis.template else {
238 return Ok(AwsCliCommandValidation {
239 operation_kind: classification.kind,
240 status: AwsCliCommandValidationStatus::Skipped,
241 template_source: None,
242 resource_types: synthesis.resource_types,
243 reason: synthesis.reason,
244 report: None,
245 template: None,
246 });
247 };
248
249 let mut report =
250 validate_bytes_with_path(engine, schema_validator, &template, aws_cli_validate_config(), file_path)?;
251 if synthesis.source != Some(AwsCliTemplateSource::TemplateBody) {
252 suppress_template_authoring_advice(&mut report);
253 }
254 if let Some(properties) = synthesis.diagnostic_properties.as_ref() {
255 scope_synthesized_report(&mut report, properties);
256 }
257 Ok(AwsCliCommandValidation {
258 operation_kind: classification.kind,
259 status: AwsCliCommandValidationStatus::Validated,
260 template_source: synthesis.source,
261 resource_types: synthesis.resource_types,
262 reason: synthesis.reason,
263 report: Some(report.to_report(DetailLevel::Standard)),
264 template: Some(template),
265 })
266}
267
268fn aws_cli_validate_config() -> ValidateConfig {
276 ValidateConfig { detail_level: DetailLevel::Standard, severity_level: Severity::Warn, ..ValidateConfig::default() }
277}
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
279#[serde(rename_all = "lowercase")]
280enum AdapterPhase {
281 Create,
282 Update,
283 Delete,
284}
285
286#[derive(Debug, Clone, PartialEq, Deserialize)]
287struct PropertyMapping {
288 source: String,
289 target: String,
290 #[serde(default)]
293 unrepresentable: Option<UnrepresentableDomain>,
294}
295
296#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
305#[serde(default)]
306struct UnrepresentableDomain {
307 rejected_values: Vec<serde_json::Value>,
309 allowed_values: Vec<serde_json::Value>,
312 minimum: Option<f64>,
313 maximum: Option<f64>,
314 min_length: Option<u64>,
315 max_length: Option<u64>,
316 pattern: Option<PatternDivergence>,
317 items: Option<Box<UnrepresentableDomain>>,
318 tag_key: Option<Box<UnrepresentableDomain>>,
319 tag_value: Option<Box<UnrepresentableDomain>>,
320}
321
322#[derive(Debug, Clone, PartialEq, Deserialize)]
330struct PatternDivergence {
331 #[serde(default)]
334 api: Option<String>,
335 cloudformation: String,
337}
338
339impl PatternDivergence {
340 fn rejects(&self, value: &str) -> Result<bool, String> {
347 let Some(cloudformation) = compile_pattern(&self.cloudformation) else {
348 return Ok(false);
349 };
350 let api = self
351 .api
352 .as_deref()
353 .map(|api_pattern| {
354 compile_pattern(api_pattern).ok_or_else(|| format!("API pattern '{api_pattern}' cannot be compiled"))
355 })
356 .transpose()?;
357 let api_accepts = api.is_none_or(|api| api.is_match(value));
358 Ok(api_accepts && !cloudformation.is_match(value))
359 }
360}
361
362const API_DOES_NOT_ENFORCE: &str = "a constraint the API does not enforce";
366
367impl UnrepresentableDomain {
368 fn rejection(&self, value: &AwsCliValue) -> Result<Option<String>, String> {
372 if let Some(json_value) = value.json_value() {
373 if self.rejected_values.contains(&json_value) {
374 return Ok(Some(format!(
375 "value {json_value} is outside the CloudFormation enum, {API_DOES_NOT_ENFORCE}"
376 )));
377 }
378 if !self.allowed_values.is_empty() && !self.allowed_values.contains(&json_value) {
379 return Ok(Some(format!(
380 "value {json_value} is not one of the CloudFormation values {}, {API_DOES_NOT_ENFORCE}",
381 serde_json::Value::Array(self.allowed_values.clone())
382 )));
383 }
384 }
385 match value {
386 AwsCliValue::String { value } => {
387 if let Some(rejection) = self.length_rejection(value.chars().count() as u64, "length") {
388 return Ok(Some(rejection));
389 }
390 self.pattern_rejection(value)
391 }
392 AwsCliValue::Integer { value } => Ok(self.numeric_rejection(*value as f64)),
393 AwsCliValue::UnsignedInteger { value } => Ok(self.numeric_rejection(*value as f64)),
394 AwsCliValue::Number { value } => Ok(self.numeric_rejection(*value)),
395 AwsCliValue::Array { items } => {
396 if let Some(rejection) = self.length_rejection(items.len() as u64, "item count") {
397 return Ok(Some(rejection));
398 }
399 let Some(domain) = self.items.as_ref() else {
400 return Ok(None);
401 };
402 for item in items {
403 if let Some(rejection) = domain.rejection(item)? {
404 return Ok(Some(rejection));
405 }
406 }
407 Ok(None)
408 }
409 AwsCliValue::Object { entries } => {
410 if let Some(rejection) = self.length_rejection(entries.len() as u64, "entry count") {
411 return Ok(Some(rejection));
412 }
413 for (key, entry) in entries {
414 if let Some(domain) = self.tag_key.as_ref()
415 && let Some(rejection) = domain.rejection(&AwsCliValue::String { value: key.clone() })?
416 {
417 return Ok(Some(rejection));
418 }
419 if let Some(domain) = self.tag_value.as_ref()
420 && let Some(rejection) = domain.rejection(entry)?
421 {
422 return Ok(Some(rejection));
423 }
424 }
425 Ok(None)
426 }
427 AwsCliValue::Null
428 | AwsCliValue::Boolean { .. }
429 | AwsCliValue::Bytes { .. }
430 | AwsCliValue::Unsupported { .. } => Ok(None),
431 }
432 }
433
434 fn pattern_rejection(&self, value: &str) -> Result<Option<String>, String> {
435 let Some(divergence) = self.pattern.as_ref() else {
436 return Ok(None);
437 };
438 if divergence.rejects(value)? {
439 return Ok(Some(format!(
440 "value {value:?} does not match the CloudFormation pattern '{}', {API_DOES_NOT_ENFORCE}",
441 divergence.cloudformation
442 )));
443 }
444 Ok(None)
445 }
446
447 fn numeric_rejection(&self, value: f64) -> Option<String> {
448 if let Some(minimum) = self.minimum
449 && value < minimum
450 {
451 return Some(format!(
452 "value {value} is below the CloudFormation minimum {minimum}, {API_DOES_NOT_ENFORCE}"
453 ));
454 }
455 if let Some(maximum) = self.maximum
456 && value > maximum
457 {
458 return Some(format!(
459 "value {value} is above the CloudFormation maximum {maximum}, {API_DOES_NOT_ENFORCE}"
460 ));
461 }
462 None
463 }
464
465 fn length_rejection(&self, length: u64, measure: &str) -> Option<String> {
466 if let Some(min_length) = self.min_length
467 && length < min_length
468 {
469 return Some(format!(
470 "{measure} {length} is below the CloudFormation minimum {min_length}, {API_DOES_NOT_ENFORCE}"
471 ));
472 }
473 if let Some(max_length) = self.max_length
474 && length > max_length
475 {
476 return Some(format!(
477 "{measure} {length} is above the CloudFormation maximum {max_length}, {API_DOES_NOT_ENFORCE}"
478 ));
479 }
480 None
481 }
482}
483
484#[derive(Debug, Clone, PartialEq, Deserialize)]
485struct OperationAdapter {
486 service: String,
487 operation: String,
488 phase: AdapterPhase,
489 cfn_type: String,
490 mappings: Vec<PropertyMapping>,
491 #[serde(default)]
493 ignored_inputs: Vec<String>,
494}
495
496#[derive(Debug, Deserialize)]
497struct OperationCatalog {
498 format_version: u32,
499 adapters: Vec<OperationAdapter>,
500}
501
502static ADAPTER_REGISTRY: LazyLock<Result<HashMap<(String, String), OperationAdapter>, String>> =
516 LazyLock::new(|| parse_adapter_registry(&data_source::embedded::AWS_CLI_OPERATION_CATALOG_BYTES));
517
518fn parse_adapter_registry(bytes: &[u8]) -> Result<HashMap<(String, String), OperationAdapter>, String> {
519 let catalog: OperationCatalog = serde_json::from_slice(bytes)
520 .map_err(|error| format!("embedded AWS CLI operation catalog is invalid: {error}"))?;
521 if catalog.format_version != 1 {
522 return Err(format!("unsupported AWS CLI operation catalog format {}", catalog.format_version));
523 }
524 let mut registry = HashMap::new();
525 for adapter in catalog.adapters {
526 if adapter.service.trim().is_empty()
527 || adapter.operation.trim().is_empty()
528 || adapter.cfn_type.trim().is_empty()
529 {
530 return Err("AWS CLI operation catalog identities must not be blank".into());
531 }
532 let key = (normalize_service(&adapter.service), adapter.operation.clone());
533 if let Some(previous) = registry.insert(key, adapter) {
534 return Err(format!("duplicate AWS CLI operation catalog key {}:{}", previous.service, previous.operation));
535 }
536 }
537 Ok(registry)
538}
539
540fn adapter_registry() -> Result<&'static HashMap<(String, String), OperationAdapter>, ValidationError> {
541 ADAPTER_REGISTRY.as_ref().map_err(|message| ValidationError::Engine(message.clone()))
542}
543
544const TEMPLATE_BODY_OPERATIONS: &[(&str, &str)] = &[
548 ("cloudformation", "CreateChangeSet"),
549 ("cloudformation", "CreateStack"),
550 ("cloudformation", "CreateStackSet"),
551 ("cloudformation", "EstimateTemplateCost"),
552 ("cloudformation", "GetTemplateSummary"),
553 ("cloudformation", "UpdateStack"),
554 ("cloudformation", "UpdateStackSet"),
555 ("cloudformation", "ValidateTemplate"),
556];
557
558fn normalize_service(name: &str) -> String {
560 name.to_ascii_lowercase()
561}
562
563fn lookup_adapter(service: &str, operation: &str) -> Result<Option<&'static OperationAdapter>, ValidationError> {
564 let key = (normalize_service(service), operation.to_string());
565 Ok(adapter_registry()?.get(&key))
566}
567
568fn is_template_body_operation(service: &str, operation: &str) -> bool {
569 let normalized = normalize_service(service);
570 TEMPLATE_BODY_OPERATIONS.iter().any(|(s, o)| normalize_service(s) == normalized && *o == operation)
571}
572
573fn template_body_operation_kind(service: &str, operation: &str) -> Option<AwsCliOperationKind> {
574 if normalize_service(service) != "cloudformation" {
575 return None;
576 }
577 match operation {
578 "CreateChangeSet" | "CreateStack" | "CreateStackSet" => Some(AwsCliOperationKind::CloudFormationCreate),
579 "UpdateStack" | "UpdateStackSet" => Some(AwsCliOperationKind::CloudFormationUpdate),
580 "EstimateTemplateCost" | "GetTemplateSummary" | "ValidateTemplate" => Some(AwsCliOperationKind::ReadOnly),
581 _ => None,
582 }
583}
584
585fn is_cloud_control_service(service: &str) -> bool {
586 normalize_service(service) == "cloudcontrol"
587}
588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589enum OperationPhase {
590 Read,
591 Create,
592 Update,
593 Delete,
594 Data,
595 Unknown,
596}
597
598#[derive(Debug, Clone)]
599struct Classification {
600 kind: AwsCliOperationKind,
601 candidates: Vec<String>,
602}
603
604const READ_VERBS: &[&str] = &[
605 "Calculate",
606 "Check",
607 "Compare",
608 "Contains",
609 "Count",
610 "Decode",
611 "Describe",
612 "Discover",
613 "Estimate",
614 "Filter",
615 "Find",
616 "Forecast",
617 "Get",
618 "Head",
619 "Is",
620 "List",
621 "Lookup",
622 "Preview",
623 "Query",
624 "Read",
625 "Resolve",
626 "Retrieve",
627 "Sample",
628 "Scan",
629 "Search",
630 "Select",
631 "Simulate",
632 "Validate",
633 "Verify",
634 "View",
635];
636const DATA_PLANE_VERBS: &[&str] = &[
637 "Analyze",
638 "Chat",
639 "Complete",
640 "Convert",
641 "Converse",
642 "Decrypt",
643 "Deliver",
644 "Encrypt",
645 "Execute",
646 "Generate",
647 "Infer",
648 "Invoke",
649 "Meter",
650 "Notify",
651 "Post",
652 "Predict",
653 "Publish",
654 "Receive",
655 "Recognize",
656 "Render",
657 "Respond",
658 "Send",
659 "Signal",
660 "Sign",
661 "Stream",
662 "Synthesize",
663 "Test",
664 "Translate",
665 "Upload",
666 "Write",
667];
668
669const MODIFIER_PREFIXES: &[&str] = &["Admin", "Batch", "Bulk", "Transact"];
670
671fn classify_operation(
672 request: &AwsCliCommand,
673 schema_validator: &SchemaValidator,
674) -> Result<Classification, ValidationError> {
675 if let Some(kind) = template_body_operation_kind(&request.service_name, &request.operation_name) {
676 return Ok(Classification { kind, candidates: Vec::new() });
677 }
678
679 if request.is_read_only == Some(true) {
681 return Ok(Classification { kind: AwsCliOperationKind::ReadOnly, candidates: Vec::new() });
682 }
683
684 if let Some(adapter) = lookup_adapter(&request.service_name, &request.operation_name)? {
685 let kind = match adapter.phase {
686 AdapterPhase::Create => AwsCliOperationKind::CloudFormationCreate,
687 AdapterPhase::Update => AwsCliOperationKind::CloudFormationUpdate,
688 AdapterPhase::Delete => AwsCliOperationKind::CloudFormationDelete,
689 };
690 return Ok(Classification { kind, candidates: vec![adapter.cfn_type.clone()] });
691 }
692
693 let words = operation_words(&request.operation_name);
694 let verb = effective_verb(&words);
695 let phase = operation_phase(request, verb);
696
697 if phase == OperationPhase::Read {
698 return Ok(Classification { kind: AwsCliOperationKind::ReadOnly, candidates: Vec::new() });
699 }
700 if phase == OperationPhase::Data {
701 return Ok(Classification { kind: AwsCliOperationKind::DataPlaneMutation, candidates: Vec::new() });
702 }
703
704 if is_cloud_control_service(&request.service_name) {
705 let is_resource_op =
706 matches!(request.operation_name.as_str(), "CreateResource" | "UpdateResource" | "DeleteResource");
707 if is_resource_op && let Some(type_name) = explicit_valid_type_name(request, schema_validator) {
708 let kind = match request.operation_name.as_str() {
709 "CreateResource" => AwsCliOperationKind::CloudFormationCreate,
710 "DeleteResource" => AwsCliOperationKind::CloudFormationDelete,
711 _ => AwsCliOperationKind::UnmappedMutation,
712 };
713 return Ok(Classification { kind, candidates: vec![type_name] });
714 }
715 }
716
717 let kind = if DATA_PLANE_IF_UNMAPPED_VERBS.contains(&verb) {
719 AwsCliOperationKind::DataPlaneMutation
720 } else {
721 AwsCliOperationKind::UnmappedMutation
722 };
723 Ok(Classification { kind, candidates: Vec::new() })
724}
725
726const DATA_PLANE_IF_UNMAPPED_VERBS: &[&str] =
727 &["Execute", "Invoke", "Post", "Publish", "Put", "Send", "Upload", "Write"];
728
729fn operation_phase(request: &AwsCliCommand, verb: &str) -> OperationPhase {
730 if request.is_read_only == Some(true) || READ_VERBS.contains(&verb) {
731 return OperationPhase::Read;
732 }
733 if DATA_PLANE_VERBS.contains(&verb) {
734 return OperationPhase::Data;
735 }
736 if CREATE_VERBS.contains(&verb) {
737 return OperationPhase::Create;
738 }
739 if UPDATE_VERBS.contains(&verb) {
740 return OperationPhase::Update;
741 }
742 if DELETE_VERBS.contains(&verb) {
743 return OperationPhase::Delete;
744 }
745 match request.http_method.as_deref().map(str::to_ascii_uppercase).as_deref() {
746 Some("GET" | "HEAD") => return OperationPhase::Read,
747 Some("DELETE") => return OperationPhase::Delete,
748 _ => {}
749 }
750 OperationPhase::Unknown
751}
752
753const CREATE_VERBS: &[&str] = &[
754 "Add",
755 "Allocate",
756 "Build",
757 "Clone",
758 "Copy",
759 "Create",
760 "Define",
761 "Deploy",
762 "Import",
763 "Index",
764 "Initialize",
765 "Install",
766 "Instantiate",
767 "Invite",
768 "Issue",
769 "Join",
770 "Launch",
771 "Provision",
772 "Purchase",
773 "Register",
774 "Request",
775 "Restore",
776 "Run",
777 "Schedule",
778 "Start",
779 "Submit",
780];
781const UPDATE_VERBS: &[&str] = &[
782 "Accept",
783 "Activate",
784 "Apply",
785 "Approve",
786 "Assign",
787 "Associate",
788 "Attach",
789 "Authorize",
790 "Change",
791 "Configure",
792 "Connect",
793 "Deactivate",
794 "Decrease",
795 "Disable",
796 "Disassociate",
797 "Dissociate",
798 "Detach",
799 "Enable",
800 "Grant",
801 "Increase",
802 "Link",
803 "Lock",
804 "Merge",
805 "Modify",
806 "Move",
807 "Promote",
808 "Put",
809 "Reboot",
810 "Refresh",
811 "Replace",
812 "Reset",
813 "Resize",
814 "Restart",
815 "Resume",
816 "Rotate",
817 "Set",
818 "Share",
819 "Subscribe",
820 "Suspend",
821 "Swap",
822 "Tag",
823 "Transfer",
824 "Unassign",
825 "Unlock",
826 "Unshare",
827 "Unsubscribe",
828 "Untag",
829 "Update",
830 "Upgrade",
831];
832const DELETE_VERBS: &[&str] = &[
833 "Abort",
834 "Block",
835 "Cancel",
836 "Close",
837 "Decline",
838 "Delete",
839 "Deny",
840 "Deprovision",
841 "Deregister",
842 "Destroy",
843 "Discard",
844 "Dispose",
845 "Expire",
846 "Forget",
847 "Leave",
848 "Purge",
849 "Reject",
850 "Release",
851 "Remove",
852 "Retire",
853 "Revoke",
854 "Shutdown",
855 "Stop",
856 "Terminate",
857 "Unregister",
858];
859
860fn operation_words(operation_name: &str) -> Vec<String> {
861 let characters: Vec<char> = operation_name.chars().collect();
862 if characters.is_empty() {
863 return Vec::new();
864 }
865 let mut words = Vec::new();
866 let mut start = 0;
867 for index in 1..characters.len() {
868 let previous = characters[index - 1];
869 let current = characters[index];
870 let next = characters.get(index + 1).copied();
871 let boundary = (current.is_ascii_digit() && !previous.is_ascii_digit())
872 || (!current.is_ascii_digit() && previous.is_ascii_digit())
873 || (current.is_ascii_uppercase() && previous.is_ascii_lowercase())
874 || (current.is_ascii_uppercase()
875 && previous.is_ascii_uppercase()
876 && next.is_some_and(|next| next.is_ascii_lowercase()));
877 if boundary {
878 words.push(characters[start..index].iter().collect());
879 start = index;
880 }
881 }
882 words.push(characters[start..].iter().collect());
883 words
884}
885
886fn effective_verb(words: &[String]) -> &str {
887 if words.len() > 1 && MODIFIER_PREFIXES.contains(&words[0].as_str()) {
888 &words[1]
889 } else {
890 words.first().map(String::as_str).unwrap_or("")
891 }
892}
893
894fn explicit_valid_type_name(request: &AwsCliCommand, schema_validator: &SchemaValidator) -> Option<String> {
895 match request.parameters.get("TypeName") {
896 Some(AwsCliValue::String { value }) if schema_validator.has_resource_type(value) => Some(value.clone()),
897 _ => None,
898 }
899}
900struct Synthesis {
901 template: Option<Vec<u8>>,
902 source: Option<AwsCliTemplateSource>,
903 reason: String,
904 resource_types: Vec<String>,
905 diagnostic_properties: Option<BTreeSet<String>>,
906}
907
908impl Synthesis {
909 fn skipped(reason: impl Into<String>, resource_types: Vec<String>) -> Self {
910 Self { template: None, source: None, reason: reason.into(), resource_types, diagnostic_properties: None }
911 }
912}
913
914fn synthesize_request(
915 request: &AwsCliCommand,
916 classification: &Classification,
917 schema_validator: &SchemaValidator,
918) -> Result<Synthesis, ValidationError> {
919 let is_cfn_op = is_template_body_operation(&request.service_name, &request.operation_name);
920
921 if is_cfn_op {
922 if let Some(template) = template_body_bytes(request.parameters.get("TemplateBody")) {
923 return Ok(Synthesis {
924 template: Some(template),
925 source: Some(AwsCliTemplateSource::TemplateBody),
926 reason: "using exact request TemplateBody".into(),
927 resource_types: Vec::new(),
928 diagnostic_properties: None,
929 });
930 }
931 if request.parameters.contains_key("TemplateURL") {
932 return Ok(Synthesis::skipped("TemplateURL content is unavailable to the offline validator", Vec::new()));
933 }
934 }
935
936 if classification.kind == AwsCliOperationKind::ReadOnly {
937 return Ok(Synthesis::skipped("read-only calls do not need validation", Vec::new()));
938 }
939
940 let is_cloud_control = is_cloud_control_service(&request.service_name);
941 if is_cloud_control
942 && request.operation_name == "CreateResource"
943 && request.parameters.contains_key("TypeName")
944 && request.parameters.contains_key("DesiredState")
945 {
946 return desired_state_template(request, schema_validator);
947 }
948
949 if is_cloud_control && request.operation_name == "UpdateResource" {
950 let type_names = explicit_valid_type_name(request, schema_validator).map(|t| vec![t]).unwrap_or_default();
951 return Ok(Synthesis::skipped(
952 "Cloud Control UpdateResource uses PatchDocument and cannot be synthesized",
953 type_names,
954 ));
955 }
956
957 adapter_template(request, classification, schema_validator)
958}
959
960fn template_body_bytes(value: Option<&AwsCliValue>) -> Option<Vec<u8>> {
961 match value {
962 Some(AwsCliValue::Bytes { value }) if !value.is_empty() => Some(value.clone()),
963 Some(AwsCliValue::String { value }) if !value.is_empty() => Some(value.as_bytes().to_vec()),
964 _ => None,
965 }
966}
967
968fn desired_state_template(
969 request: &AwsCliCommand,
970 schema_validator: &SchemaValidator,
971) -> Result<Synthesis, ValidationError> {
972 let Some(AwsCliValue::String { value: type_name }) = request.parameters.get("TypeName") else {
973 return Ok(Synthesis::skipped("DesiredState has no known CloudFormation TypeName", Vec::new()));
974 };
975 if !schema_validator.has_resource_type(type_name) {
976 return Ok(Synthesis::skipped("DesiredState has no known CloudFormation TypeName", Vec::new()));
977 }
978 let Some(desired_state) = request.parameters.get("DesiredState") else {
979 return Ok(Synthesis::skipped("DesiredState is missing", vec![type_name.clone()]));
980 };
981 let properties = match desired_state {
982 AwsCliValue::String { value } if !value.is_empty() => serde_json::from_str(value),
983 AwsCliValue::Bytes { value } if !value.is_empty() => serde_json::from_slice(value),
984 _ => return Ok(Synthesis::skipped("DesiredState is missing", vec![type_name.clone()])),
985 };
986 let properties: serde_json::Value = match properties {
987 Ok(properties) => properties,
988 Err(_) => return Ok(Synthesis::skipped("DesiredState is not valid JSON", vec![type_name.clone()])),
989 };
990 let Some(properties) = properties.as_object() else {
991 return Ok(Synthesis::skipped("DesiredState is not a JSON object", vec![type_name.clone()]));
992 };
993 Ok(Synthesis {
994 template: Some(resource_template(type_name, properties)?),
995 source: Some(AwsCliTemplateSource::CloudControlDesiredState),
996 reason: "wrapped exact Cloud Control desired state".into(),
997 resource_types: vec![type_name.clone()],
998 diagnostic_properties: None,
999 })
1000}
1001
1002fn adapter_template(
1003 request: &AwsCliCommand,
1004 classification: &Classification,
1005 schema_validator: &SchemaValidator,
1006) -> Result<Synthesis, ValidationError> {
1007 if !matches!(
1008 classification.kind,
1009 AwsCliOperationKind::CloudFormationCreate | AwsCliOperationKind::CloudFormationUpdate
1010 ) {
1011 return Ok(Synthesis::skipped(
1012 "classification has no representable resource state",
1013 classification.candidates.clone(),
1014 ));
1015 }
1016 if classification.candidates.len() != 1 {
1017 return Ok(Synthesis::skipped(
1018 "no adapter maps this operation to a CloudFormation resource",
1019 classification.candidates.clone(),
1020 ));
1021 }
1022
1023 let type_name = &classification.candidates[0];
1024 let Some(schema) = schema_validator.resource_schema_metadata(type_name) else {
1025 return Ok(Synthesis::skipped("CloudFormation resource type is unknown", vec![type_name.clone()]));
1026 };
1027
1028 let adapter = lookup_adapter(&request.service_name, &request.operation_name)?;
1029 let Some(adapter) = adapter else {
1030 return Ok(Synthesis::skipped(
1031 "no adapter maps this operation to a CloudFormation resource",
1032 vec![type_name.clone()],
1033 ));
1034 };
1035
1036 let properties = match map_adapter_properties(&request.parameters, &schema, adapter)? {
1037 AdapterMappingResult::Mapped(properties) => properties,
1038 AdapterMappingResult::Skip(reason) => {
1039 return Ok(Synthesis::skipped(reason, vec![type_name.clone()]));
1040 }
1041 };
1042
1043 if properties.is_empty() {
1044 return Ok(Synthesis::skipped("no request parameters map to resource properties", vec![type_name.clone()]));
1045 }
1046
1047 let diagnostic_properties = Some(properties.keys().cloned().collect::<BTreeSet<_>>());
1048 let source = if adapter.phase == AdapterPhase::Update {
1049 AwsCliTemplateSource::SynthesizedUpdate
1050 } else {
1051 AwsCliTemplateSource::SynthesizedCreate
1052 };
1053 let reason = if adapter.phase == AdapterPhase::Update {
1054 "synthesized explicitly updated CloudFormation properties"
1055 } else {
1056 "synthesized one unambiguous CloudFormation resource"
1057 };
1058 let template_properties: serde_json::Map<String, serde_json::Value> = properties.into_iter().collect();
1059 Ok(Synthesis {
1060 template: Some(resource_template(type_name, &template_properties)?),
1061 source: Some(source),
1062 reason: reason.into(),
1063 resource_types: vec![type_name.clone()],
1064 diagnostic_properties,
1065 })
1066}
1067
1068#[derive(Debug)]
1069enum AdapterMappingResult {
1070 Mapped(BTreeMap<String, serde_json::Value>),
1071 Skip(String),
1072}
1073
1074fn map_adapter_properties(
1075 parameters: &HashMap<String, AwsCliValue>,
1076 schema: &ResourceSchemaMetadata,
1077 adapter: &OperationAdapter,
1078) -> Result<AdapterMappingResult, ValidationError> {
1079 let mut excluded = schema.read_only_properties.clone();
1080 if adapter.phase == AdapterPhase::Update {
1081 excluded.extend(schema.primary_identifier_properties.iter().cloned());
1082 }
1083
1084 let mut sources = BTreeSet::new();
1085 let mut targets = BTreeSet::new();
1086 let mut mapped_sources: BTreeSet<&str> = BTreeSet::new();
1087 let mut mapped = BTreeMap::new();
1088 for mapping in &adapter.mappings {
1089 if !sources.insert(mapping.source.as_str()) {
1090 return Err(ValidationError::Engine(format!(
1091 "adapter {}:{} has duplicate source parameter '{}'",
1092 adapter.service, adapter.operation, mapping.source
1093 )));
1094 }
1095 if !targets.insert(mapping.target.as_str()) {
1096 return Err(ValidationError::Engine(format!(
1097 "adapter {}:{} has duplicate target property '{}'",
1098 adapter.service, adapter.operation, mapping.target
1099 )));
1100 }
1101 let Some(accepted_types) = schema.property_types.get(&mapping.target) else {
1102 return Err(ValidationError::Engine(format!(
1103 "adapter {}:{} targets property '{}' which does not exist on {}",
1104 adapter.service, adapter.operation, mapping.target, adapter.cfn_type
1105 )));
1106 };
1107 if excluded.contains(&mapping.target) {
1108 return Err(ValidationError::Engine(format!(
1109 "adapter {}:{} targets excluded property '{}' on {}",
1110 adapter.service, adapter.operation, mapping.target, adapter.cfn_type
1111 )));
1112 }
1113 let Some(value) = parameters.get(&mapping.source) else {
1114 continue;
1115 };
1116 mapped_sources.insert(&mapping.source);
1117 if let Some(domain) = mapping.unrepresentable.as_ref() {
1118 let rejection = domain.rejection(value).map_err(|error| {
1119 ValidationError::Engine(format!(
1120 "adapter {}:{} mapping '{}' -> '{}' has an unevaluable unrepresentable domain: {error}",
1121 adapter.service, adapter.operation, mapping.source, mapping.target
1122 ))
1123 })?;
1124 if let Some(rejection) = rejection {
1125 return Ok(AdapterMappingResult::Skip(format!(
1126 "parameter '{}' {rejection}, so it has no representation as property '{}' on {}",
1127 mapping.source, mapping.target, adapter.cfn_type
1128 )));
1129 }
1130 }
1131 match mapped_value(value, accepted_types, &mapping.target) {
1132 Some(json_value) => {
1133 mapped.insert(mapping.target.clone(), json_value);
1134 }
1135 None => {
1136 return Ok(AdapterMappingResult::Skip(format!(
1137 "parameter '{}' cannot be represented as property '{}' on {}",
1138 mapping.source, mapping.target, adapter.cfn_type
1139 )));
1140 }
1141 }
1142 }
1143
1144 let mut ignored: BTreeSet<&str> = adapter.ignored_inputs.iter().map(String::as_str).collect();
1147 if adapter.phase == AdapterPhase::Update {
1148 ignored.extend(schema.primary_identifier_properties.iter().map(String::as_str));
1149 }
1150
1151 for param_name in parameters.keys().collect::<BTreeSet<_>>() {
1155 if mapped_sources.contains(param_name.as_str()) {
1156 continue;
1157 }
1158 if ignored.contains(param_name.as_str()) {
1159 continue;
1160 }
1161 return Ok(AdapterMappingResult::Skip(format!(
1162 "parameter '{}' has no mapping to a property on {}",
1163 param_name, adapter.cfn_type
1164 )));
1165 }
1166 Ok(AdapterMappingResult::Mapped(mapped))
1167}
1168
1169fn resource_template(
1170 type_name: &str,
1171 properties: &serde_json::Map<String, serde_json::Value>,
1172) -> Result<Vec<u8>, ValidationError> {
1173 serde_json::to_vec(&serde_json::json!({
1174 "AWSTemplateFormatVersion": "2010-09-09",
1175 "Resources": {
1176 "Resource": {
1177 "Type": type_name,
1178 "Properties": properties,
1179 }
1180 }
1181 }))
1182 .map_err(|error| ValidationError::Engine(format!("failed to serialize synthesized template: {error}")))
1183}
1184
1185fn mapped_value(
1186 value: &AwsCliValue,
1187 accepted_types: &BTreeSet<PropertyValueType>,
1188 property_name: &str,
1189) -> Option<serde_json::Value> {
1190 if property_name == "Tags"
1193 && accepts_type(accepted_types, PropertyValueType::Array)
1194 && let AwsCliValue::Object { entries } = value
1195 && entries.values().all(|value| matches!(value, AwsCliValue::String { .. }))
1196 {
1197 let mut tags: Vec<(&String, &AwsCliValue)> = entries.iter().collect();
1198 tags.sort_by_key(|(key, _)| key.as_str());
1199 return Some(serde_json::Value::Array(
1200 tags.into_iter()
1201 .filter_map(|(key, value)| match value {
1202 AwsCliValue::String { value } => Some(serde_json::json!({"Key": key, "Value": value})),
1203 _ => None,
1204 })
1205 .collect(),
1206 ));
1207 }
1208 if value_matches_types(value, accepted_types) {
1209 return value.json_value();
1210 }
1211 None
1212}
1213
1214fn accepts_type(types: &BTreeSet<PropertyValueType>, expected: PropertyValueType) -> bool {
1215 types.contains(&PropertyValueType::Any) || types.contains(&expected)
1216}
1217
1218fn value_matches_types(value: &AwsCliValue, types: &BTreeSet<PropertyValueType>) -> bool {
1219 let accepts_any = types.contains(&PropertyValueType::Any);
1220 match value {
1221 AwsCliValue::Array { items } => {
1222 (accepts_any || types.contains(&PropertyValueType::Array)) && items.iter().all(is_scalar_api_value)
1223 }
1224 AwsCliValue::Object { .. } => false,
1225 AwsCliValue::Boolean { .. } => accepts_any || types.contains(&PropertyValueType::Boolean),
1226 AwsCliValue::Integer { .. } | AwsCliValue::UnsignedInteger { .. } => {
1227 accepts_any || types.contains(&PropertyValueType::Integer) || types.contains(&PropertyValueType::Number)
1228 }
1229 AwsCliValue::Number { .. } => accepts_any || types.contains(&PropertyValueType::Number),
1230 AwsCliValue::String { .. } => accepts_any || types.contains(&PropertyValueType::String),
1231 AwsCliValue::Null | AwsCliValue::Bytes { .. } | AwsCliValue::Unsupported { .. } => false,
1232 }
1233}
1234
1235fn is_scalar_api_value(value: &AwsCliValue) -> bool {
1236 matches!(
1237 value,
1238 AwsCliValue::Boolean { .. }
1239 | AwsCliValue::Integer { .. }
1240 | AwsCliValue::UnsignedInteger { .. }
1241 | AwsCliValue::Number { .. }
1242 | AwsCliValue::String { .. }
1243 )
1244}
1245const TEMPLATE_AUTHORING_ADVICE_RULES: &[&str] = &["W1054", "W2501", "W3010", "W9002", "W9010", "W9013"];
1251
1252fn suppress_template_authoring_advice(report: &mut ValidationReport) {
1256 retain_diagnostics(report, |diagnostic| !TEMPLATE_AUTHORING_ADVICE_RULES.contains(&diagnostic.rule_id.as_str()));
1257}
1258
1259fn scope_synthesized_report(report: &mut ValidationReport, properties: &BTreeSet<String>) {
1260 retain_diagnostics(report, |diagnostic| {
1261 diagnostic.property_path.as_deref().is_some_and(|path| diagnostic_in_scope(path, properties))
1262 });
1263}
1264
1265fn retain_diagnostics(report: &mut ValidationReport, keep: impl FnMut(&diagnostics::Diagnostic) -> bool) {
1266 let before = report.diagnostics.len();
1267 report.diagnostics.retain(keep);
1268 let removed = before.saturating_sub(report.diagnostics.len()) as u32;
1269 report.metadata.suppressed = report.metadata.suppressed.saturating_add(removed);
1270 report.metadata.counts = summarize_diagnostics(&report.diagnostics);
1271}
1272
1273fn diagnostic_in_scope(property_path: &str, properties: &BTreeSet<String>) -> bool {
1274 properties.iter().any(|property_name| {
1275 [format!("Properties.{property_name}"), format!("/Properties/{property_name}")].into_iter().any(|marker| {
1276 property_path.find(&marker).is_some_and(|start| {
1277 let end = start + marker.len();
1278 end == property_path.len()
1279 || property_path[end..].chars().next().is_some_and(|separator| matches!(separator, '.' | '[' | '/'))
1280 })
1281 })
1282 })
1283}
1284
1285fn summarize_diagnostics(diagnostics: &[diagnostics::Diagnostic]) -> Summary {
1286 let fatal = diagnostics.iter().filter(|diagnostic| diagnostic.severity == Severity::Fatal).count() as u32;
1287 let errors = diagnostics.iter().filter(|diagnostic| diagnostic.severity == Severity::Error).count() as u32;
1288 let warnings = diagnostics.iter().filter(|diagnostic| diagnostic.severity == Severity::Warn).count() as u32;
1289 let debug = diagnostics.iter().filter(|diagnostic| diagnostic.severity == Severity::Debug).count() as u32;
1290 let informational = diagnostics.len() as u32 - fatal - errors - warnings - debug;
1291 Summary { fatal, errors, warnings, informational, debug }
1292}
1293#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 use diagnostics::{Diagnostic, PhaseMetric, RegisteredDiagnostic};
1297 use rules::{RuleInfo, RuleMetadataEntry};
1298 use std::sync::Arc;
1299 use template_model::SemanticModel;
1300
1301 struct NoopEngine {
1302 metadata: HashMap<String, RuleMetadataEntry>,
1303 init_metric: PhaseMetric,
1304 }
1305
1306 impl Default for NoopEngine {
1307 fn default() -> Self {
1308 Self { metadata: HashMap::new(), init_metric: PhaseMetric { duration_ms: 0.0 } }
1309 }
1310 }
1311
1312 impl ValidationEngine for NoopEngine {
1313 fn engine_name(&self) -> &str {
1314 "noop"
1315 }
1316
1317 fn evaluate_rules(
1318 &self,
1319 _model: &Arc<SemanticModel>,
1320 _config: &ValidateConfig,
1321 ) -> Result<Vec<Diagnostic>, ValidationError> {
1322 Ok(Vec::new())
1323 }
1324
1325 fn list_rules(&self) -> Vec<RuleInfo> {
1326 Vec::new()
1327 }
1328
1329 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
1330 &self.metadata
1331 }
1332
1333 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
1334 HashMap::new()
1335 }
1336
1337 fn init_metric(&self) -> &PhaseMetric {
1338 &self.init_metric
1339 }
1340 }
1341
1342 fn value(value: serde_json::Value) -> AwsCliValue {
1343 AwsCliValue::from_json(value)
1344 }
1345
1346 fn request(service: &str, operation: &str, parameters: serde_json::Value) -> AwsCliCommand {
1347 let parameters: HashMap<String, AwsCliValue> = parameters
1348 .as_object()
1349 .expect("test parameters must be an object")
1350 .iter()
1351 .map(|(name, value)| (name.clone(), AwsCliValue::from_json(value.clone())))
1352 .collect();
1353 AwsCliCommand::new(service, operation, parameters).with_http_method("POST")
1354 }
1355
1356 fn synthesized_json(request: &AwsCliCommand) -> (Classification, Synthesis, serde_json::Value) {
1357 let schema_validator = SchemaValidator::default();
1358 let classification = classify_operation(request, &schema_validator).expect("classification succeeds");
1359 let synthesis = synthesize_request(request, &classification, &schema_validator).expect("synthesis succeeds");
1360 let template = synthesis.template.as_ref().expect("request must synthesize");
1361 let document = serde_json::from_slice(template).expect("template must be JSON");
1362 (classification, synthesis, document)
1363 }
1364
1365 fn mapping(source: &str, target: &str) -> PropertyMapping {
1366 PropertyMapping { source: source.into(), target: target.into(), unrepresentable: None }
1367 }
1368
1369 fn s3_create_bucket_adapter(mappings: Vec<PropertyMapping>) -> OperationAdapter {
1370 OperationAdapter {
1371 service: "s3".into(),
1372 operation: "CreateBucket".into(),
1373 phase: AdapterPhase::Create,
1374 cfn_type: "AWS::S3::Bucket".into(),
1375 mappings,
1376 ignored_inputs: Vec::new(),
1377 }
1378 }
1379
1380 fn map_s3_create_bucket(adapter: &OperationAdapter, parameters: serde_json::Value) -> AdapterMappingResult {
1381 let schema_validator = SchemaValidator::default();
1382 let schema = schema_validator.resource_schema_metadata("AWS::S3::Bucket").expect("S3 bucket schema must exist");
1383 let parameters: HashMap<String, AwsCliValue> = parameters
1384 .as_object()
1385 .expect("test parameters must be an object")
1386 .iter()
1387 .map(|(name, value)| (name.clone(), AwsCliValue::from_json(value.clone())))
1388 .collect();
1389 map_adapter_properties(¶meters, &schema, adapter).expect("well-formed adapter must not error")
1390 }
1391
1392 fn skip_reason(result: AdapterMappingResult) -> String {
1393 match result {
1394 AdapterMappingResult::Skip(reason) => reason,
1395 AdapterMappingResult::Mapped(properties) => panic!("expected skip, got mapped properties {properties:?}"),
1396 }
1397 }
1398
1399 fn malformed_adapter_error(mappings: Vec<PropertyMapping>) -> String {
1400 let schema_validator = SchemaValidator::default();
1401 let schema = schema_validator.resource_schema_metadata("AWS::S3::Bucket").expect("S3 bucket schema must exist");
1402 let adapter = s3_create_bucket_adapter(mappings);
1403 match map_adapter_properties(&HashMap::new(), &schema, &adapter)
1404 .expect_err("malformed adapter must return an error")
1405 {
1406 ValidationError::Engine(message) => message,
1407 error => panic!("expected engine error, got {error:?}"),
1408 }
1409 }
1410
1411 #[test]
1412 fn catalog_parser_reads_unrepresentable_domains() {
1413 let catalog = br#"{
1414 "format_version": 1,
1415 "adapters": [{
1416 "service": "s3", "operation": "CreateBucket", "phase": "create", "cfn_type": "AWS::S3::Bucket",
1417 "mappings": [
1418 {"source": "Bucket", "target": "BucketName",
1419 "unrepresentable": {"rejected_values": ["legacy"], "min_length": 3, "max_length": 63}},
1420 {"source": "Tags", "target": "Tags",
1421 "unrepresentable": {"tag_value": {"min_length": 1}, "items": {"maximum": 10.0}}},
1422 {"source": "Region", "target": "Region",
1423 "unrepresentable": {"pattern": {"api": "^[a-z]{2,4}$", "cloudformation": "^[a-z]{2}$"}}}
1424 ]
1425 }]
1426 }"#;
1427 let registry = parse_adapter_registry(catalog).expect("catalog with domains parses");
1428 let adapter = ®istry[&("s3".to_string(), "CreateBucket".to_string())];
1429 assert_eq!(
1430 adapter.mappings[0].unrepresentable,
1431 Some(UnrepresentableDomain {
1432 rejected_values: vec![serde_json::json!("legacy")],
1433 min_length: Some(3),
1434 max_length: Some(63),
1435 ..UnrepresentableDomain::default()
1436 })
1437 );
1438 let tags = adapter.mappings[1].unrepresentable.as_ref().expect("tags domain parses");
1439 assert_eq!(tags.tag_value.as_deref().and_then(|domain| domain.min_length), Some(1));
1440 assert_eq!(tags.items.as_deref().and_then(|domain| domain.maximum), Some(10.0));
1441 assert_eq!(
1442 adapter.mappings[2].unrepresentable.as_ref().and_then(|domain| domain.pattern.clone()),
1443 Some(PatternDivergence { api: Some("^[a-z]{2,4}$".into()), cloudformation: "^[a-z]{2}$".into() })
1444 );
1445 }
1446
1447 #[test]
1448 fn pattern_divergence_rejects_only_values_the_api_accepts_and_cloudformation_rejects() {
1449 let sqs_arn = UnrepresentableDomain {
1450 pattern: Some(PatternDivergence {
1451 api: Some(r"^arn:aws[a-z-]*:sqs:[a-z]{2,4}(-[a-z]+)+-\d:\d{12}:.+$".into()),
1452 cloudformation: r"^arn:aws[a-z-]*:sqs:[a-z]{2}(-[a-z]+)+-\d:\d{12}:.+$".into(),
1453 }),
1454 ..UnrepresentableDomain::default()
1455 };
1456 let sovereign = value(serde_json::json!("arn:aws-eusc:sqs:eusc-de-east-1:123456789012:queue"));
1457 let rejection = sqs_arn.rejection(&sovereign).expect("domain evaluates").expect("value is unrepresentable");
1458 assert!(rejection.contains("CloudFormation pattern"), "{rejection}");
1459 assert!(rejection.contains(API_DOES_NOT_ENFORCE), "{rejection}");
1460 assert!(
1461 sqs_arn
1462 .rejection(&value(serde_json::json!("arn:aws:sqs:us-east-1:123456789012:queue")))
1463 .expect("domain evaluates")
1464 .is_none(),
1465 "a value both patterns accept is representable"
1466 );
1467 assert!(
1468 sqs_arn.rejection(&value(serde_json::json!("not-an-arn"))).expect("domain evaluates").is_none(),
1469 "a value both patterns reject keeps its CloudFormation finding"
1470 );
1471 assert!(
1472 sqs_arn
1473 .rejection(&value(serde_json::json!(" arn:aws:sqs:eusc-de-east-1:123456789012:q")))
1474 .expect("domain evaluates")
1475 .is_none(),
1476 "the API pattern applies to the whole value"
1477 );
1478
1479 let nested = UnrepresentableDomain {
1480 items: Some(Box::new(sqs_arn.clone())),
1481 tag_value: Some(Box::new(sqs_arn)),
1482 ..UnrepresentableDomain::default()
1483 };
1484 assert!(
1485 nested
1486 .rejection(&value(serde_json::json!([sovereign.json_value().unwrap()])))
1487 .expect("domain evaluates")
1488 .is_some()
1489 );
1490 assert!(
1491 nested
1492 .rejection(&value(serde_json::json!({"Queue": sovereign.json_value().unwrap()})))
1493 .expect("domain evaluates")
1494 .is_some()
1495 );
1496 }
1497
1498 #[test]
1499 fn cloudformation_only_pattern_rejects_every_value_outside_it() {
1500 let table_name = UnrepresentableDomain {
1501 pattern: Some(PatternDivergence { api: None, cloudformation: "^[a-zA-Z0-9_.-]+$".into() }),
1502 ..UnrepresentableDomain::default()
1503 };
1504 let table_arn = value(serde_json::json!("arn:aws:dynamodb:us-east-1:123456789012:table/Orders"));
1505 let rejection = table_name.rejection(&table_arn).expect("domain evaluates").expect("ARN is unrepresentable");
1506 assert!(rejection.contains("does not match the CloudFormation pattern"), "{rejection}");
1507 assert!(rejection.contains(API_DOES_NOT_ENFORCE), "{rejection}");
1508 assert!(
1509 table_name.rejection(&value(serde_json::json!("Orders"))).expect("domain evaluates").is_none(),
1510 "a value inside the CloudFormation pattern is representable"
1511 );
1512 }
1513
1514 #[test]
1515 fn cloudformation_only_enum_rejects_every_value_outside_it() {
1516 let data_type = UnrepresentableDomain {
1517 allowed_values: vec![serde_json::json!("text"), serde_json::json!("aws:ec2:image")],
1518 ..UnrepresentableDomain::default()
1519 };
1520 let rejection = data_type
1521 .rejection(&value(serde_json::json!("aws:ssm:integration")))
1522 .expect("domain evaluates")
1523 .expect("value outside the CloudFormation enum is unrepresentable");
1524 assert!(rejection.contains("is not one of the CloudFormation values"), "{rejection}");
1525 assert!(rejection.contains("aws:ec2:image"), "{rejection}");
1526 assert!(rejection.contains(API_DOES_NOT_ENFORCE), "{rejection}");
1527 assert!(data_type.rejection(&value(serde_json::json!("text"))).expect("domain evaluates").is_none());
1528 assert!(
1529 data_type.rejection(&value(serde_json::json!(7))).expect("domain evaluates").is_some(),
1530 "a non-string value is outside a string enum"
1531 );
1532 }
1533
1534 #[test]
1535 fn tag_map_entry_count_outside_cloudformation_item_bounds_is_unrepresentable() {
1536 let tags =
1537 UnrepresentableDomain { min_length: Some(1), max_length: Some(2), ..UnrepresentableDomain::default() };
1538 let empty = tags.rejection(&value(serde_json::json!({}))).expect("domain evaluates").expect("empty map");
1539 assert!(empty.contains("entry count 0"), "{empty}");
1540 assert!(empty.contains("minimum 1"), "{empty}");
1541 let crowded = tags
1542 .rejection(&value(serde_json::json!({"a": "1", "b": "2", "c": "3"})))
1543 .expect("domain evaluates")
1544 .expect("three entries above the maximum");
1545 assert!(crowded.contains("entry count 3"), "{crowded}");
1546 assert!(crowded.contains("maximum 2"), "{crowded}");
1547 assert!(tags.rejection(&value(serde_json::json!({"a": "1"}))).expect("domain evaluates").is_none());
1548 }
1549
1550 #[test]
1551 fn aws_cli_config_is_fixed_at_standard_detail_and_warn_severity() {
1552 let preset = aws_cli_validate_config();
1553 assert_eq!(preset.detail_level, DetailLevel::Standard);
1554 assert_eq!(preset.severity_level, Severity::Warn);
1555 assert!(!preset.strict);
1556 assert!(!preset.disable_builtin_rules);
1557 }
1558
1559 #[test]
1560 fn synthesized_create_reports_omit_info_findings() {
1561 let schema_validator = SchemaValidator::default();
1562 let engine = NoopEngine::default();
1563 let create_bucket = request("s3", "CreateBucket", serde_json::json!({"Bucket": "my-bucket"}));
1564 let validation =
1565 validate_aws_cli_command(&engine, &schema_validator, &create_bucket).expect("validation succeeds");
1566 let report = validation.report.expect("bucket request is validated");
1567 assert_eq!(report.metadata.severity_level, Severity::Warn);
1568 assert!(
1569 report.diagnostics.iter().all(|diagnostic| diagnostic.severity >= Severity::Warn),
1570 "info findings must be gated: {:?}",
1571 report.diagnostics
1572 );
1573 assert!(
1574 report.metadata.suppressed >= 1,
1575 "the create-only replacement note on BucketName is an Info finding that the floor removes"
1576 );
1577 }
1578
1579 #[test]
1580 fn uncompilable_pattern_divergence_is_a_catalog_error_not_a_silent_skip() {
1581 let broken = UnrepresentableDomain {
1582 pattern: Some(PatternDivergence { api: Some("^(unclosed$".into()), cloudformation: "^[a-z]+$".into() }),
1583 ..UnrepresentableDomain::default()
1584 };
1585 let error = broken.rejection(&value(serde_json::json!("abc"))).expect_err("broken pattern must error");
1586 assert!(error.contains("cannot be compiled"), "{error}");
1587
1588 let adapter = s3_create_bucket_adapter(vec![PropertyMapping {
1589 source: "Bucket".into(),
1590 target: "BucketName".into(),
1591 unrepresentable: Some(broken),
1592 }]);
1593 let schema_validator = SchemaValidator::default();
1594 let schema = schema_validator.resource_schema_metadata("AWS::S3::Bucket").expect("S3 bucket schema must exist");
1595 let parameters = HashMap::from([("Bucket".to_string(), value(serde_json::json!("abc")))]);
1596 match map_adapter_properties(¶meters, &schema, &adapter).expect_err("unevaluable domain must error") {
1597 ValidationError::Engine(message) => {
1598 assert!(message.contains("unevaluable unrepresentable domain"), "{message}")
1599 }
1600 error => panic!("expected engine error, got {error:?}"),
1601 }
1602 }
1603
1604 #[test]
1605 fn adapter_skips_when_supplied_value_diverges_from_the_cloudformation_pattern() {
1606 let adapter = s3_create_bucket_adapter(vec![PropertyMapping {
1607 source: "Bucket".into(),
1608 target: "BucketName".into(),
1609 unrepresentable: Some(UnrepresentableDomain {
1610 pattern: Some(PatternDivergence {
1611 api: Some("^[a-z0-9.-]+$".into()),
1612 cloudformation: "^[a-z0-9-]+$".into(),
1613 }),
1614 ..UnrepresentableDomain::default()
1615 }),
1616 }]);
1617 let reason = skip_reason(map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "my.bucket"})));
1618 assert!(reason.contains("parameter 'Bucket'"), "{reason}");
1619 assert!(reason.contains("does not match the CloudFormation pattern"), "{reason}");
1620 match map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "my-bucket"})) {
1621 AdapterMappingResult::Mapped(properties) => {
1622 assert_eq!(properties.get("BucketName"), Some(&serde_json::json!("my-bucket")));
1623 }
1624 AdapterMappingResult::Skip(reason) => panic!("value inside both patterns must map: {reason}"),
1625 }
1626 }
1627
1628 fn collect_api_patterns<'a>(domain: &'a UnrepresentableDomain, patterns: &mut Vec<&'a str>) {
1629 if let Some(divergence) = domain.pattern.as_ref() {
1630 patterns.extend(divergence.api.as_deref());
1631 }
1632 for nested in [&domain.items, &domain.tag_key, &domain.tag_value].into_iter().flatten() {
1633 collect_api_patterns(nested, patterns);
1634 }
1635 }
1636
1637 #[test]
1638 fn every_api_pattern_in_the_embedded_catalog_compiles() {
1639 let mut patterns = Vec::new();
1640 for adapter in adapter_registry().expect("embedded catalog parses").values() {
1641 for mapping in &adapter.mappings {
1642 if let Some(domain) = mapping.unrepresentable.as_ref() {
1643 collect_api_patterns(domain, &mut patterns);
1644 }
1645 }
1646 }
1647 let uncompilable: Vec<&str> =
1648 patterns.iter().copied().filter(|pattern| compile_pattern(pattern).is_none()).collect();
1649 assert!(uncompilable.is_empty(), "catalog API patterns must compile: {uncompilable:?}");
1650 }
1651
1652 #[test]
1653 fn uncompilable_cloudformation_pattern_rejects_nothing_like_the_schema_validator() {
1654 let unclosed_group = UnrepresentableDomain {
1655 pattern: Some(PatternDivergence { api: None, cloudformation: "^(unclosed$".into() }),
1656 ..UnrepresentableDomain::default()
1657 };
1658 assert!(
1659 unclosed_group.rejection(&value(serde_json::json!("anything"))).expect("domain evaluates").is_none(),
1660 "a pattern the schema validator cannot apply produces no finding to guard against"
1661 );
1662 }
1663
1664 #[test]
1665 fn unrepresentable_domain_rejects_only_values_outside_cloudformation_bounds() {
1666 let enumeration = UnrepresentableDomain {
1667 rejected_values: vec![serde_json::json!("MANAGED_INSTANCES")],
1668 ..UnrepresentableDomain::default()
1669 };
1670 assert!(
1671 enumeration.rejection(&value(serde_json::json!("MANAGED_INSTANCES"))).expect("domain evaluates").is_some()
1672 );
1673 assert!(enumeration.rejection(&value(serde_json::json!("FARGATE"))).expect("domain evaluates").is_none());
1674
1675 let numeric =
1676 UnrepresentableDomain { minimum: Some(1.0), maximum: Some(10.0), ..UnrepresentableDomain::default() };
1677 assert!(numeric.rejection(&value(serde_json::json!(0))).expect("domain evaluates").is_some());
1678 assert!(numeric.rejection(&value(serde_json::json!(50))).expect("domain evaluates").is_some());
1679 assert!(numeric.rejection(&value(serde_json::json!(10))).expect("domain evaluates").is_none());
1680 assert!(numeric.rejection(&value(serde_json::json!(1))).expect("domain evaluates").is_none());
1681 assert!(numeric.rejection(&AwsCliValue::UnsignedInteger { value: 11 }).expect("domain evaluates").is_some());
1682 assert!(numeric.rejection(&AwsCliValue::Number { value: 0.5 }).expect("domain evaluates").is_some());
1683
1684 let text_length =
1685 UnrepresentableDomain { min_length: Some(5), max_length: Some(6), ..UnrepresentableDomain::default() };
1686 assert!(text_length.rejection(&value(serde_json::json!("abc"))).expect("domain evaluates").is_some());
1687 assert!(text_length.rejection(&value(serde_json::json!("abcdefg"))).expect("domain evaluates").is_some());
1688 assert!(text_length.rejection(&value(serde_json::json!("abcde"))).expect("domain evaluates").is_none());
1689 assert!(
1690 text_length.rejection(&value(serde_json::json!("ééééé"))).expect("domain evaluates").is_none(),
1691 "length counts characters"
1692 );
1693
1694 let list = UnrepresentableDomain {
1695 max_length: Some(2),
1696 items: Some(Box::new(UnrepresentableDomain {
1697 rejected_values: vec![serde_json::json!("SUPER_USER")],
1698 ..UnrepresentableDomain::default()
1699 })),
1700 ..UnrepresentableDomain::default()
1701 };
1702 assert!(
1703 list.rejection(&value(serde_json::json!(["a", "b", "c"]))).expect("domain evaluates").is_some(),
1704 "item count above maximum"
1705 );
1706 assert!(
1707 list.rejection(&value(serde_json::json!(["SELECT", "SUPER_USER"]))).expect("domain evaluates").is_some(),
1708 "rejected item"
1709 );
1710 assert!(list.rejection(&value(serde_json::json!(["SELECT", "ALTER"]))).expect("domain evaluates").is_none());
1711
1712 let tags = UnrepresentableDomain {
1713 tag_key: Some(Box::new(UnrepresentableDomain { max_length: Some(3), ..UnrepresentableDomain::default() })),
1714 tag_value: Some(Box::new(UnrepresentableDomain {
1715 min_length: Some(1),
1716 ..UnrepresentableDomain::default()
1717 })),
1718 ..UnrepresentableDomain::default()
1719 };
1720 assert!(
1721 tags.rejection(&value(serde_json::json!({"Team": "cli"}))).expect("domain evaluates").is_some(),
1722 "key too long"
1723 );
1724 assert!(
1725 tags.rejection(&value(serde_json::json!({"Env": ""}))).expect("domain evaluates").is_some(),
1726 "empty value"
1727 );
1728 assert!(tags.rejection(&value(serde_json::json!({"Env": "prod"}))).expect("domain evaluates").is_none());
1729
1730 let empty = UnrepresentableDomain::default();
1731 assert!(empty.rejection(&value(serde_json::json!(true))).expect("domain evaluates").is_none());
1732 assert!(empty.rejection(&value(serde_json::json!(null))).expect("domain evaluates").is_none());
1733 }
1734
1735 #[test]
1736 fn adapter_skips_when_supplied_value_is_valid_for_the_api_but_not_for_cloudformation() {
1737 let adapter = s3_create_bucket_adapter(vec![PropertyMapping {
1738 source: "Bucket".into(),
1739 target: "BucketName".into(),
1740 unrepresentable: Some(UnrepresentableDomain {
1741 rejected_values: vec![serde_json::json!("legacy")],
1742 min_length: Some(3),
1743 ..UnrepresentableDomain::default()
1744 }),
1745 }]);
1746
1747 let reason = skip_reason(map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "legacy"})));
1748 assert!(reason.contains("parameter 'Bucket'"), "{reason}");
1749 assert!(reason.contains("is outside the CloudFormation enum"), "{reason}");
1750 assert!(reason.contains(API_DOES_NOT_ENFORCE), "{reason}");
1751 assert!(reason.contains("property 'BucketName' on AWS::S3::Bucket"), "{reason}");
1752
1753 let reason = skip_reason(map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "ab"})));
1754 assert!(reason.contains("length 2"), "{reason}");
1755 assert!(reason.contains("minimum 3"), "{reason}");
1756
1757 match map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "my-bucket"})) {
1758 AdapterMappingResult::Mapped(properties) => {
1759 assert_eq!(properties.get("BucketName"), Some(&serde_json::json!("my-bucket")));
1760 }
1761 AdapterMappingResult::Skip(reason) => panic!("representable value must map: {reason}"),
1762 }
1763 }
1764
1765 #[test]
1766 fn adapter_skips_tag_maps_whose_entries_have_no_cloudformation_representation() {
1767 let adapter = s3_create_bucket_adapter(vec![PropertyMapping {
1768 source: "Tags".into(),
1769 target: "Tags".into(),
1770 unrepresentable: Some(UnrepresentableDomain {
1771 tag_value: Some(Box::new(UnrepresentableDomain {
1772 min_length: Some(1),
1773 ..UnrepresentableDomain::default()
1774 })),
1775 ..UnrepresentableDomain::default()
1776 }),
1777 }]);
1778
1779 let reason = skip_reason(map_s3_create_bucket(&adapter, serde_json::json!({"Tags": {"Env": ""}})));
1780 assert!(reason.contains("parameter 'Tags'"), "{reason}");
1781 assert!(reason.contains("length 0"), "{reason}");
1782
1783 match map_s3_create_bucket(&adapter, serde_json::json!({"Tags": {"Env": "prod"}})) {
1784 AdapterMappingResult::Mapped(properties) => {
1785 assert_eq!(properties.get("Tags"), Some(&serde_json::json!([{"Key": "Env", "Value": "prod"}])));
1786 }
1787 AdapterMappingResult::Skip(reason) => panic!("representable tags must map: {reason}"),
1788 }
1789 }
1790 #[test]
1791 fn catalog_parser_rejects_invalid_formats_and_normalized_duplicates() {
1792 assert!(parse_adapter_registry(b"not json").expect_err("invalid JSON must fail").contains("invalid"));
1793 assert!(
1794 parse_adapter_registry(br#"{"format_version":2,"adapters":[]}"#)
1795 .expect_err("unsupported format must fail")
1796 .contains("unsupported")
1797 );
1798 let duplicate = br#"{
1799 "format_version": 1,
1800 "adapters": [
1801 {"service":"S3","operation":"CreateBucket","phase":"create","cfn_type":"AWS::S3::Bucket","mappings":[]},
1802 {"service":"s3","operation":"CreateBucket","phase":"create","cfn_type":"AWS::S3::Bucket","mappings":[]}
1803 ]
1804 }"#;
1805 assert!(
1806 parse_adapter_registry(duplicate).expect_err("case-normalized duplicate must fail").contains("duplicate")
1807 );
1808 let blank = br#"{
1809 "format_version": 1,
1810 "adapters": [
1811 {"service":"","operation":"CreateBucket","phase":"create","cfn_type":"AWS::S3::Bucket","mappings":[]}
1812 ]
1813 }"#;
1814 assert!(parse_adapter_registry(blank).expect_err("blank identity must fail").contains("blank"));
1815 }
1816
1817 #[test]
1818 fn catalog_covers_the_generated_resource_universe() {
1819 let registry = adapter_registry().expect("catalog loads");
1820 let creates = registry.values().filter(|a| a.phase == AdapterPhase::Create).count();
1821 let deletes = registry.values().filter(|a| a.phase == AdapterPhase::Delete).count();
1822 assert!(registry.len() >= 2000, "catalog unexpectedly small: {}", registry.len());
1823 assert!(creates >= 1000, "create adapters unexpectedly few: {creates}");
1824 assert!(deletes >= 900, "delete adapters unexpectedly few: {deletes}");
1825 }
1826
1827 #[test]
1828 fn catalog_operations_synthesize_beyond_the_original_services() {
1829 let cases = [
1830 ("ec2", "RunInstances", "AWS::EC2::Instance"),
1831 ("kms", "CreateKey", "AWS::KMS::Key"),
1832 ("logs", "CreateLogGroup", "AWS::Logs::LogGroup"),
1833 ("stepfunctions", "CreateStateMachine", "AWS::StepFunctions::StateMachine"),
1834 ("cloudwatch", "PutMetricAlarm", "AWS::CloudWatch::Alarm"),
1835 ("secretsmanager", "CreateSecret", "AWS::SecretsManager::Secret"),
1836 ];
1837 let schema_validator = SchemaValidator::default();
1838 for (service, operation, expected_type) in cases {
1839 let req = request(service, operation, serde_json::json!({}));
1840 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
1841 assert_eq!(classification.candidates, [expected_type], "{service}:{operation} must map to {expected_type}");
1842 }
1843 }
1844
1845 #[test]
1846 fn catalog_never_contains_forbidden_or_ambiguous_operations() {
1847 let forbidden = [
1848 ("ecs", "RunTask"),
1849 ("ec2", "StartInstances"),
1850 ("ec2", "StopInstances"),
1851 ("iot", "StartThingRegistrationTask"),
1852 ("lambda", "Invoke"),
1853 ("sns", "Publish"),
1854 ("sqs", "SendMessage"),
1855 ("s3", "PutObject"),
1856 ("dynamodb", "PutItem"),
1857 ("logs", "StartQuery"),
1858 ("acm", "RemoveTagsFromCertificate"),
1859 ("robomaker", "DeregisterRobot"),
1860 ("quicksight", "CreateTopic"),
1861 ("quicksight", "DeleteTopic"),
1862 ];
1863 let registry = adapter_registry().expect("catalog loads");
1864 for (service, operation) in forbidden {
1865 let key = (service.to_string(), operation.to_string());
1866 assert!(!registry.contains_key(&key), "{service}:{operation} must never be a registered adapter");
1867 }
1868 }
1869
1870 #[test]
1871 fn every_catalog_adapter_maps_cleanly_with_empty_requests() {
1872 let schema_validator = SchemaValidator::default();
1873 let empty = HashMap::new();
1874 for adapter in adapter_registry().expect("catalog loads").values() {
1875 let schema = schema_validator
1876 .resource_schema_metadata(&adapter.cfn_type)
1877 .unwrap_or_else(|| panic!("{} missing schema metadata", adapter.cfn_type));
1878 let result = map_adapter_properties(&empty, &schema, adapter).unwrap_or_else(|error| {
1879 panic!("adapter {}:{} violates registry invariants: {error}", adapter.service, adapter.operation)
1880 });
1881 assert!(
1883 matches!(result, AdapterMappingResult::Mapped(_)),
1884 "adapter {}:{} must accept empty parameters",
1885 adapter.service,
1886 adapter.operation
1887 );
1888 }
1889 }
1890
1891 #[test]
1892 fn nested_values_are_rejected_without_recursive_shape_mappings() {
1893 let object_types = BTreeSet::from([PropertyValueType::Object]);
1894 let array_types = BTreeSet::from([PropertyValueType::Array]);
1895 let object = AwsCliValue::from_json(serde_json::json!({"lowerCamel": "value"}));
1896 let object_array = AwsCliValue::from_json(serde_json::json!([{"lowerCamel": "value"}]));
1897 let scalar_array = AwsCliValue::from_json(serde_json::json!(["one", "two"]));
1898
1899 assert!(mapped_value(&object, &object_types, "Configuration").is_none());
1900 assert!(mapped_value(&object_array, &array_types, "Configurations").is_none());
1901 assert_eq!(mapped_value(&scalar_array, &array_types, "Names"), Some(serde_json::json!(["one", "two"])));
1902 }
1903
1904 #[test]
1905 fn registry_has_unique_service_operation_keys() {
1906 let mut seen = BTreeSet::new();
1907 for adapter in adapter_registry().expect("catalog loads").values() {
1908 let key = (normalize_service(&adapter.service), adapter.operation.clone());
1909 assert!(seen.insert(key.clone()), "duplicate adapter key: {}:{}", key.0, key.1);
1910 }
1911 }
1912
1913 #[test]
1914 fn registry_types_exist_in_schema_validator() {
1915 let schema_validator = SchemaValidator::default();
1916 for adapter in adapter_registry().expect("catalog loads").values() {
1917 assert!(
1918 schema_validator.has_resource_type(&adapter.cfn_type),
1919 "adapter {}:{} references unknown type {}",
1920 adapter.service,
1921 adapter.operation,
1922 adapter.cfn_type
1923 );
1924 }
1925 }
1926
1927 #[test]
1928 fn registry_property_mappings_target_real_properties() {
1929 let schema_validator = SchemaValidator::default();
1930 for adapter in adapter_registry().expect("catalog loads").values() {
1931 let Some(schema) = schema_validator.resource_schema_metadata(&adapter.cfn_type) else {
1932 continue;
1933 };
1934 for mapping in &adapter.mappings {
1935 assert!(
1936 schema.property_types.contains_key(&mapping.target),
1937 "adapter {}:{} maps to non-existent property {}.{}",
1938 adapter.service,
1939 adapter.operation,
1940 adapter.cfn_type,
1941 mapping.target
1942 );
1943 }
1944 }
1945 }
1946
1947 #[test]
1948 fn registry_has_no_read_only_property_mappings() {
1949 let schema_validator = SchemaValidator::default();
1950 for adapter in adapter_registry().expect("catalog loads").values() {
1951 let Some(schema) = schema_validator.resource_schema_metadata(&adapter.cfn_type) else {
1952 continue;
1953 };
1954 for mapping in &adapter.mappings {
1955 assert!(
1956 !schema.read_only_properties.contains(&mapping.target),
1957 "adapter {}:{} maps to read-only property {}.{}",
1958 adapter.service,
1959 adapter.operation,
1960 adapter.cfn_type,
1961 mapping.target
1962 );
1963 }
1964 }
1965 }
1966
1967 #[test]
1968 fn registry_update_mappings_exclude_primary_identifiers() {
1969 let schema_validator = SchemaValidator::default();
1970 for adapter in adapter_registry().expect("catalog loads").values() {
1971 if adapter.phase != AdapterPhase::Update {
1972 continue;
1973 }
1974 let Some(schema) = schema_validator.resource_schema_metadata(&adapter.cfn_type) else {
1975 continue;
1976 };
1977 for mapping in &adapter.mappings {
1978 assert!(
1979 !schema.primary_identifier_properties.contains(&mapping.target),
1980 "update adapter {}:{} maps to primary identifier property {}.{}",
1981 adapter.service,
1982 adapter.operation,
1983 adapter.cfn_type,
1984 mapping.target
1985 );
1986 }
1987 }
1988 }
1989
1990 #[test]
1991 fn registry_has_no_duplicate_source_or_target_mappings() {
1992 for adapter in adapter_registry().expect("catalog loads").values() {
1993 let mut sources = BTreeSet::new();
1994 let mut targets = BTreeSet::new();
1995 for mapping in &adapter.mappings {
1996 assert!(
1997 sources.insert(mapping.source.as_str()),
1998 "adapter {}:{} has duplicate source mapping: {}",
1999 adapter.service,
2000 adapter.operation,
2001 mapping.source
2002 );
2003 assert!(
2004 targets.insert(mapping.target.as_str()),
2005 "adapter {}:{} has duplicate target mapping: {}",
2006 adapter.service,
2007 adapter.operation,
2008 mapping.target
2009 );
2010 }
2011 }
2012 }
2013
2014 #[test]
2015 fn malformed_adapter_mappings_fail_without_request_values() {
2016 assert!(malformed_adapter_error(vec![mapping("Bucket", "NotAProperty")]).contains("does not exist"));
2017 assert!(malformed_adapter_error(vec![mapping("Bucket", "Arn")]).contains("excluded property"));
2018 assert!(
2019 malformed_adapter_error(vec![mapping("Bucket", "BucketName"), mapping("Bucket", "Tags")])
2020 .contains("duplicate source parameter")
2021 );
2022 assert!(
2023 malformed_adapter_error(vec![mapping("Bucket", "BucketName"), mapping("OtherBucket", "BucketName")])
2024 .contains("duplicate target property")
2025 );
2026 }
2027
2028 #[test]
2029 fn s3_create_bucket_synthesizes_with_explicit_mappings() {
2030 let request = request("s3", "CreateBucket", serde_json::json!({"Bucket": "synthetic-bucket"}));
2031 let (classification, synthesis, document) = synthesized_json(&request);
2032 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2033 assert_eq!(classification.candidates, ["AWS::S3::Bucket"]);
2034 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2035 assert_eq!(document["Resources"]["Resource"]["Properties"]["BucketName"], "synthetic-bucket");
2036 }
2037
2038 #[test]
2039 fn s3_delete_bucket_identifies_type_without_synthesizing() {
2040 let schema_validator = SchemaValidator::default();
2041 let request = request("s3", "DeleteBucket", serde_json::json!({"Bucket": "synthetic-bucket"}));
2042 let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2043 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2044 assert_eq!(classification.candidates, ["AWS::S3::Bucket"]);
2045 let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2046 assert!(synthesis.template.is_none());
2047 }
2048
2049 #[test]
2050 fn dynamodb_create_table_skips_when_unmapped_nested_parameters_are_present() {
2051 let schema_validator = SchemaValidator::default();
2052 let request = request(
2053 "dynamodb",
2054 "CreateTable",
2055 serde_json::json!({
2056 "TableName": "Synthetic",
2057 "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
2058 "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}],
2059 "BillingMode": "PAY_PER_REQUEST"
2060 }),
2061 );
2062 let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2063 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2064 assert_eq!(classification.candidates, ["AWS::DynamoDB::Table"]);
2065 let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2066 assert!(synthesis.template.is_none(), "unmapped nested parameters must skip synthesis");
2067 assert!(
2068 synthesis.reason.contains("has no mapping"),
2069 "reason must explain the unmapped parameter: {}",
2070 synthesis.reason
2071 );
2072 }
2073
2074 #[test]
2075 fn dynamodb_create_table_synthesizes_with_scalar_only_parameters() {
2076 let request = request(
2077 "dynamodb",
2078 "CreateTable",
2079 serde_json::json!({"TableName": "Synthetic", "BillingMode": "PAY_PER_REQUEST"}),
2080 );
2081 let (classification, synthesis, document) = synthesized_json(&request);
2082 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2083 assert_eq!(classification.candidates, ["AWS::DynamoDB::Table"]);
2084 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2085 assert_eq!(document["Resources"]["Resource"]["Properties"]["TableName"], "Synthetic");
2086 assert_eq!(document["Resources"]["Resource"]["Properties"]["BillingMode"], "PAY_PER_REQUEST");
2087 }
2088
2089 #[test]
2090 fn dynamodb_delete_table_identifies_type_without_synthesizing() {
2091 let schema_validator = SchemaValidator::default();
2092 let request = request("dynamodb", "DeleteTable", serde_json::json!({"TableName": "Synthetic"}));
2093 let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2094 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2095 assert_eq!(classification.candidates, ["AWS::DynamoDB::Table"]);
2096 let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2097 assert!(synthesis.template.is_none());
2098 }
2099
2100 #[test]
2101 fn iam_create_role_synthesizes_with_explicit_mappings() {
2102 let request = request(
2103 "iam",
2104 "CreateRole",
2105 serde_json::json!({
2106 "RoleName": "Synthetic",
2107 "AssumeRolePolicyDocument": "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
2108 }),
2109 );
2110 let (classification, synthesis, document) = synthesized_json(&request);
2111 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2112 assert_eq!(classification.candidates, ["AWS::IAM::Role"]);
2113 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2114 assert_eq!(document["Resources"]["Resource"]["Properties"]["RoleName"], "Synthetic");
2115 }
2116
2117 #[test]
2118 fn iam_delete_role_identifies_type_without_synthesizing() {
2119 let schema_validator = SchemaValidator::default();
2120 let request = request("iam", "DeleteRole", serde_json::json!({"RoleName": "Synthetic"}));
2121 let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2122 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2123 assert_eq!(classification.candidates, ["AWS::IAM::Role"]);
2124 let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2125 assert!(synthesis.template.is_none());
2126 }
2127
2128 #[test]
2129 fn lambda_create_function_synthesizes_all_supplied_scalar_properties() {
2130 let request =
2131 request("lambda", "CreateFunction", serde_json::json!({"FunctionName": "Synthetic", "MemorySize": 128}));
2132 let (classification, synthesis, document) = synthesized_json(&request);
2133 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2134 assert_eq!(classification.candidates, ["AWS::Lambda::Function"]);
2135 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2136 assert_eq!(synthesis.diagnostic_properties, Some(BTreeSet::from(["FunctionName".into(), "MemorySize".into()])));
2137 assert_eq!(document["Resources"]["Resource"]["Properties"]["FunctionName"], "Synthetic");
2138 assert_eq!(document["Resources"]["Resource"]["Properties"]["MemorySize"], 128);
2139 }
2140
2141 #[test]
2142 fn lambda_update_function_configuration_maps_all_supplied_mutable_properties() {
2143 let request = request(
2144 "lambda",
2145 "UpdateFunctionConfiguration",
2146 serde_json::json!({"FunctionName": "Synthetic", "MemorySize": 128}),
2147 );
2148 let (classification, synthesis, document) = synthesized_json(&request);
2149 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationUpdate);
2150 assert_eq!(classification.candidates, ["AWS::Lambda::Function"]);
2151 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedUpdate));
2152 assert_eq!(document["Resources"]["Resource"]["Properties"], serde_json::json!({"MemorySize": 128}));
2153 assert_eq!(synthesis.diagnostic_properties, Some(BTreeSet::from(["MemorySize".into()])));
2154 }
2155
2156 #[test]
2157 fn lambda_delete_function_identifies_type_without_synthesizing() {
2158 let schema_validator = SchemaValidator::default();
2159 let request = request("lambda", "DeleteFunction", serde_json::json!({"FunctionName": "Synthetic"}));
2160 let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2161 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2162 assert_eq!(classification.candidates, ["AWS::Lambda::Function"]);
2163 let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2164 assert!(synthesis.template.is_none());
2165 }
2166
2167 #[test]
2168 fn sns_create_topic_synthesizes_with_explicit_mappings() {
2169 let request = request("sns", "CreateTopic", serde_json::json!({"Name": "Synthetic"}));
2170 let (classification, synthesis, document) = synthesized_json(&request);
2171 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2172 assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2173 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2174 assert_eq!(document["Resources"]["Resource"]["Properties"]["TopicName"], "Synthetic");
2175 }
2176
2177 #[test]
2178 fn sns_delete_topic_identifies_type_without_synthesizing() {
2179 let schema_validator = SchemaValidator::default();
2180 let request = request("sns", "DeleteTopic", serde_json::json!({"TopicArn": "arn:aws:sns:us-east-1:123:Topic"}));
2181 let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2182 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2183 assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2184 let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2185 assert!(synthesis.template.is_none());
2186 }
2187
2188 #[test]
2189 fn sqs_create_queue_synthesizes_with_explicit_mappings() {
2190 let request =
2191 request("sqs", "CreateQueue", serde_json::json!({"QueueName": "Synthetic", "tags": {"Team": "CLI"}}));
2192 let (classification, synthesis, document) = synthesized_json(&request);
2193 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2194 assert_eq!(classification.candidates, ["AWS::SQS::Queue"]);
2195 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2196 assert_eq!(document["Resources"]["Resource"]["Properties"]["QueueName"], "Synthetic");
2197 }
2198
2199 #[test]
2200 fn sqs_delete_queue_identifies_type_without_synthesizing() {
2201 let schema_validator = SchemaValidator::default();
2202 let request =
2203 request("sqs", "DeleteQueue", serde_json::json!({"QueueUrl": "https://sqs.us-east-1.amazonaws.com/123/Q"}));
2204 let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2205 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2206 assert_eq!(classification.candidates, ["AWS::SQS::Queue"]);
2207 let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2208 assert!(synthesis.template.is_none());
2209 }
2210 #[test]
2211 fn canonical_service_name_matching_is_case_insensitive() {
2212 let schema_validator = SchemaValidator::default();
2213 for (service, operation, expected_type) in [
2216 ("S3", "CreateBucket", "AWS::S3::Bucket"),
2217 ("DynamoDb", "CreateTable", "AWS::DynamoDB::Table"),
2218 ("Iam", "CreateRole", "AWS::IAM::Role"),
2219 ("Lambda", "CreateFunction", "AWS::Lambda::Function"),
2220 ("Sns", "CreateTopic", "AWS::SNS::Topic"),
2221 ("Sqs", "CreateQueue", "AWS::SQS::Queue"),
2222 ] {
2223 let req = request(service, operation, serde_json::json!({}));
2224 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2225 assert_eq!(
2226 classification.candidates,
2227 [expected_type],
2228 "canonical service name {service}:{operation} must resolve case-insensitively to {expected_type}"
2229 );
2230 }
2231 }
2232 #[test]
2233 fn template_body_is_accepted_only_for_cloudformation_operations() {
2234 let schema_validator = SchemaValidator::default();
2235 let template = br#"{"Resources":{}}"#.to_vec();
2236
2237 let mut cfn_request = request("cloudformation", "CreateChangeSet", serde_json::json!({}));
2238 cfn_request.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: template.clone() });
2239 let classification = classify_operation(&cfn_request, &schema_validator).expect("classification succeeds");
2240 let synthesis =
2241 synthesize_request(&cfn_request, &classification, &schema_validator).expect("synthesis succeeds");
2242 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::TemplateBody));
2243 assert_eq!(synthesis.template, Some(template.clone()));
2244
2245 let mut s3_request = request("s3", "PutObject", serde_json::json!({}));
2246 s3_request.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: template.clone() });
2247 let classification = classify_operation(&s3_request, &schema_validator).expect("classification succeeds");
2248 let synthesis =
2249 synthesize_request(&s3_request, &classification, &schema_validator).expect("synthesis succeeds");
2250 assert_ne!(synthesis.source, Some(AwsCliTemplateSource::TemplateBody));
2251 }
2252
2253 #[test]
2254 fn template_url_skip_only_for_cloudformation_operations() {
2255 let schema_validator = SchemaValidator::default();
2256 let cfn_request = request(
2257 "cloudformation",
2258 "CreateStack",
2259 serde_json::json!({"TemplateURL": "https://example.com/template.json"}),
2260 );
2261 let classification = classify_operation(&cfn_request, &schema_validator).expect("classification succeeds");
2262 let synthesis =
2263 synthesize_request(&cfn_request, &classification, &schema_validator).expect("synthesis succeeds");
2264 assert!(synthesis.template.is_none());
2265 assert!(synthesis.reason.contains("unavailable"));
2266 }
2267
2268 #[test]
2269 fn all_closed_template_body_operations_are_accepted() {
2270 let schema_validator = SchemaValidator::default();
2271 let template = br#"{"Resources":{}}"#.to_vec();
2272 for (service, operation) in TEMPLATE_BODY_OPERATIONS {
2273 let mut req = request(service, operation, serde_json::json!({}));
2274 req.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: template.clone() });
2275 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2276 let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2277 assert_eq!(
2278 synthesis.source,
2279 Some(AwsCliTemplateSource::TemplateBody),
2280 "{service}:{operation} should accept TemplateBody"
2281 );
2282 }
2283 }
2284 #[test]
2285 fn cloud_control_create_resource_wraps_desired_state() {
2286 let known = request(
2287 "cloudcontrol",
2288 "CreateResource",
2289 serde_json::json!({"TypeName": "AWS::SNS::Topic", "DesiredState": "{\"TopicName\":\"Synthetic\"}"}),
2290 );
2291 let (classification, synthesis, document) = synthesized_json(&known);
2292 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2293 assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2294 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::CloudControlDesiredState));
2295 assert_eq!(document["Resources"]["Resource"]["Properties"]["TopicName"], "Synthetic");
2296 }
2297
2298 #[test]
2299 fn cloud_control_with_signing_prefix_cloudcontrolapi() {
2300 let parameters: HashMap<String, AwsCliValue> = serde_json::json!({
2301 "TypeName": "AWS::SNS::Topic",
2302 "DesiredState": "{\"TopicName\":\"Synthetic\"}"
2303 })
2304 .as_object()
2305 .unwrap()
2306 .iter()
2307 .map(|(k, v)| (k.clone(), AwsCliValue::from_json(v.clone())))
2308 .collect();
2309 let req =
2310 AwsCliCommand::new("cloudcontrol", "CreateResource", parameters).with_service_prefix("cloudcontrolapi");
2311 let (classification, synthesis, document) = synthesized_json(&req);
2312 assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2313 assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2314 assert_eq!(synthesis.source, Some(AwsCliTemplateSource::CloudControlDesiredState));
2315 assert_eq!(document["Resources"]["Resource"]["Properties"]["TopicName"], "Synthetic");
2316 }
2317
2318 #[test]
2319 fn cloud_control_rejects_unknown_type_name() {
2320 let schema_validator = SchemaValidator::default();
2321 let unknown = request(
2322 "cloudcontrol",
2323 "CreateResource",
2324 serde_json::json!({"TypeName": "AWS::Unknown::Type", "DesiredState": "{}"}),
2325 );
2326 let classification = classify_operation(&unknown, &schema_validator).expect("classification succeeds");
2327 let synthesis = synthesize_request(&unknown, &classification, &schema_validator).expect("synthesis succeeds");
2328 assert!(synthesis.template.is_none());
2329 assert!(synthesis.reason.contains("known CloudFormation TypeName"));
2330 }
2331
2332 #[test]
2333 fn cloud_control_update_resource_reports_type_but_does_not_synthesize() {
2334 let schema_validator = SchemaValidator::default();
2335 let update = request(
2336 "cloudcontrol",
2337 "UpdateResource",
2338 serde_json::json!({"TypeName": "AWS::SNS::Topic", "PatchDocument": "[{\"op\":\"replace\"}]"}),
2339 );
2340 let classification = classify_operation(&update, &schema_validator).expect("classification succeeds");
2341 assert_eq!(classification.kind, AwsCliOperationKind::UnmappedMutation);
2342 assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2343 let synthesis = synthesize_request(&update, &classification, &schema_validator).expect("synthesis succeeds");
2344 assert!(synthesis.template.is_none());
2345 assert!(synthesis.reason.contains("PatchDocument"));
2346 assert_eq!(synthesis.resource_types, ["AWS::SNS::Topic"]);
2347 }
2348 #[test]
2349 fn explicit_readonly_and_http_get_are_authoritative_read_signals() {
2350 let schema_validator = SchemaValidator::default();
2351 let mut explicitly_readonly = request("test", "CreateThing", serde_json::json!({}));
2352 explicitly_readonly.is_read_only = Some(true);
2353 assert_eq!(
2354 classify_operation(&explicitly_readonly, &schema_validator).expect("classification succeeds").kind,
2355 AwsCliOperationKind::ReadOnly
2356 );
2357 let mut get_request = request("test", "FrobnicateThing", serde_json::json!({}));
2358 get_request.http_method = Some("GET".into());
2359 assert_eq!(
2360 classify_operation(&get_request, &schema_validator).expect("classification succeeds").kind,
2361 AwsCliOperationKind::ReadOnly
2362 );
2363 }
2364
2365 #[test]
2366 fn data_plane_verbs_are_classified_correctly() {
2367 let schema_validator = SchemaValidator::default();
2368 let lambda_invoke = request("lambda", "Invoke", serde_json::json!({}));
2369 assert_eq!(
2370 classify_operation(&lambda_invoke, &schema_validator).expect("classification succeeds").kind,
2371 AwsCliOperationKind::DataPlaneMutation
2372 );
2373 }
2374 #[test]
2375 fn ecs_run_task_never_maps_to_resource() {
2376 let schema_validator = SchemaValidator::default();
2377 let req = request("ecs", "RunTask", serde_json::json!({"TaskDefinition": "my-task"}));
2378 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2379 assert!(classification.candidates.is_empty(), "ecs:RunTask must not map to any resource type");
2380 assert_ne!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2381 let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2382 assert!(synthesis.template.is_none());
2383 }
2384
2385 #[test]
2386 fn ec2_start_instances_never_maps_to_resource() {
2387 let schema_validator = SchemaValidator::default();
2388 let req = request("ec2", "StartInstances", serde_json::json!({"InstanceIds": ["i-12345"]}));
2389 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2390 assert!(classification.candidates.is_empty(), "ec2:StartInstances must not map to any resource type");
2391 assert_ne!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2392 let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2393 assert!(synthesis.template.is_none());
2394 }
2395
2396 #[test]
2397 fn iot_start_thing_registration_task_never_maps_to_resource() {
2398 let schema_validator = SchemaValidator::default();
2399 let req = request("iot", "StartThingRegistrationTask", serde_json::json!({"TemplateBody": "{}"}));
2400 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2401 assert!(
2402 classification.candidates.is_empty(),
2403 "iot:StartThingRegistrationTask must not map to any resource type"
2404 );
2405 let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2406 assert_ne!(synthesis.source, Some(AwsCliTemplateSource::TemplateBody));
2407 }
2408
2409 #[test]
2410 fn wrong_service_template_body_is_not_treated_as_cfn_template() {
2411 let schema_validator = SchemaValidator::default();
2412 let template = br#"{"Resources":{}}"#.to_vec();
2413 for service in ["s3", "lambda", "iot", "dynamodb"] {
2414 let mut req = request(service, "SomeOperation", serde_json::json!({}));
2415 req.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: template.clone() });
2416 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2417 let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2418 assert_ne!(
2419 synthesis.source,
2420 Some(AwsCliTemplateSource::TemplateBody),
2421 "{service}:SomeOperation should not treat TemplateBody as CFN template"
2422 );
2423 }
2424 }
2425
2426 #[test]
2427 fn wrong_service_type_name_desired_state_is_not_wrapped() {
2428 let schema_validator = SchemaValidator::default();
2429 let req = request(
2430 "s3",
2431 "CreateResource",
2432 serde_json::json!({"TypeName": "AWS::SNS::Topic", "DesiredState": "{\"TopicName\":\"Synthetic\"}"}),
2433 );
2434 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2435 let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2436 assert_ne!(synthesis.source, Some(AwsCliTemplateSource::CloudControlDesiredState));
2437 }
2438
2439 #[test]
2440 fn near_match_operation_names_never_map_or_synthesize() {
2441 let schema_validator = SchemaValidator::default();
2442 for (service, operation) in [
2443 ("s3", "CreateBuckets"),
2444 ("s3", "createBucket"),
2445 ("dynamodb", "CreateTables"),
2446 ("lambda", "CreateFunctions"),
2447 ("lambda", "UpdateFunctionConfigurations"),
2448 ] {
2449 let req = request(service, operation, serde_json::json!({}));
2450 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2451 assert!(
2452 classification.candidates.is_empty(),
2453 "{service}:{operation} near-match must not resolve to any adapter"
2454 );
2455 }
2456 }
2457 #[test]
2458 fn incompatible_property_value_skips_synthesis() {
2459 let schema_validator = SchemaValidator::default();
2460 let request = request(
2462 "iam",
2463 "CreateRole",
2464 serde_json::json!({
2465 "RoleName": "Synthetic",
2466 "AssumeRolePolicyDocument": "{}",
2467 "MaxSessionDuration": true
2468 }),
2469 );
2470 let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2471 let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2472 assert!(synthesis.template.is_none(), "incompatible value must skip synthesis");
2473 assert!(
2474 synthesis.reason.contains("cannot be represented"),
2475 "reason must explain the type mismatch: {}",
2476 synthesis.reason
2477 );
2478 }
2479
2480 #[test]
2481 fn high_level_api_validates_exact_template_and_reports_skips() {
2482 let engine = NoopEngine::default();
2483 let schema_validator = SchemaValidator::default();
2484 let mut exact = request("cloudformation", "CreateChangeSet", serde_json::json!({}));
2485 exact.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: br#"{"Resources":{}}"#.to_vec() });
2486 let validation = validate_aws_cli_command(&engine, &schema_validator, &exact).expect("validation succeeds");
2487 assert_eq!(validation.status, AwsCliCommandValidationStatus::Validated);
2488 assert_eq!(validation.template_source, Some(AwsCliTemplateSource::TemplateBody));
2489 assert!(validation.report.is_some());
2490 assert_eq!(
2491 validation.template,
2492 Some(br#"{"Resources":{}}"#.to_vec()),
2493 "exact TemplateBody bytes must be preserved without reserializing"
2494 );
2495
2496 let read = request("iam", "GetRole", serde_json::json!({"RoleName": "Synthetic"}));
2497 let validation = validate_aws_cli_command(&engine, &schema_validator, &read).expect("classification succeeds");
2498 assert_eq!(validation.status, AwsCliCommandValidationStatus::Skipped);
2499 assert_eq!(validation.operation_kind, AwsCliOperationKind::ReadOnly);
2500 assert!(validation.report.is_none());
2501 assert_eq!(validation.template, None, "skipped requests must have template=None");
2502 }
2503
2504 struct AdviceEngine(NoopEngine);
2507
2508 impl ValidationEngine for AdviceEngine {
2509 fn engine_name(&self) -> &str {
2510 "advice"
2511 }
2512
2513 fn evaluate_rules(
2514 &self,
2515 model: &Arc<SemanticModel>,
2516 _config: &ValidateConfig,
2517 ) -> Result<Vec<Diagnostic>, ValidationError> {
2518 Ok(model
2519 .resources
2520 .keys()
2521 .flat_map(|logical_id| {
2522 ["W9002", "W3030"].into_iter().map(move |rule_id| {
2523 RegisteredDiagnostic::new(rule_id, format!("{rule_id} on {logical_id}"))
2524 .resource(logical_id.clone(), None)
2525 .property_path("Properties.QueueName")
2526 .build()
2527 })
2528 })
2529 .collect())
2530 }
2531
2532 fn list_rules(&self) -> Vec<RuleInfo> {
2533 self.0.list_rules()
2534 }
2535
2536 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
2537 self.0.rule_metadata()
2538 }
2539
2540 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
2541 self.0.external_rule_metadata()
2542 }
2543
2544 fn init_metric(&self) -> &PhaseMetric {
2545 self.0.init_metric()
2546 }
2547 }
2548
2549 fn reported_rule_ids(validation: &AwsCliCommandValidation) -> BTreeSet<String> {
2550 let report = validation.report.as_ref().expect("request must be validated");
2551 report.diagnostics.iter().map(|diagnostic| diagnostic.rule_id.clone()).collect()
2552 }
2553
2554 #[test]
2555 fn template_authoring_advice_is_dropped_for_modeled_state_but_kept_for_template_bodies() {
2556 let engine = AdviceEngine(NoopEngine::default());
2557 let schema_validator = SchemaValidator::default();
2558
2559 let mut template_body = request("cloudformation", "CreateStack", serde_json::json!({"StackName": "s"}));
2560 template_body.parameters.insert(
2561 "TemplateBody".into(),
2562 AwsCliValue::String {
2563 value: r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"orders"}}}}"#.into(),
2564 },
2565 );
2566 let validation =
2567 validate_aws_cli_command(&engine, &schema_validator, &template_body).expect("validation succeeds");
2568 assert_eq!(validation.template_source, Some(AwsCliTemplateSource::TemplateBody));
2569 let reported = reported_rule_ids(&validation);
2570 assert!(reported.contains("W9002"), "a real template keeps authoring advice: {reported:?}");
2571 assert!(reported.contains("W3030"), "{reported:?}");
2572
2573 let synthesized = request("sqs", "CreateQueue", serde_json::json!({"QueueName": "orders"}));
2574 let validation =
2575 validate_aws_cli_command(&engine, &schema_validator, &synthesized).expect("validation succeeds");
2576 assert_eq!(validation.template_source, Some(AwsCliTemplateSource::SynthesizedCreate));
2577 let reported = reported_rule_ids(&validation);
2578 assert!(!reported.contains("W9002"), "modeled API state drops authoring advice: {reported:?}");
2579 assert!(reported.contains("W3030"), "value findings are kept: {reported:?}");
2580 let report = validation.report.as_ref().expect("validated");
2581 assert!(report.metadata.suppressed >= 1, "dropped advice is counted as suppressed");
2582 assert_eq!(report.metadata.counts.warnings, 1);
2583
2584 let desired_state = request(
2585 "cloudcontrol",
2586 "CreateResource",
2587 serde_json::json!({"TypeName": "AWS::SQS::Queue", "DesiredState": "{\"QueueName\":\"orders\"}"}),
2588 );
2589 let validation =
2590 validate_aws_cli_command(&engine, &schema_validator, &desired_state).expect("validation succeeds");
2591 assert_eq!(validation.template_source, Some(AwsCliTemplateSource::CloudControlDesiredState));
2592 let reported = reported_rule_ids(&validation);
2593 assert!(!reported.contains("W9002"), "Cloud Control desired state drops authoring advice: {reported:?}");
2594 assert!(reported.contains("W3030"), "{reported:?}");
2595 }
2596
2597 #[test]
2598 fn partial_update_scoping_keeps_report_counts_consistent() {
2599 let engine = NoopEngine::default();
2600 let schema_validator = SchemaValidator::default();
2601 let update = request(
2602 "lambda",
2603 "UpdateFunctionConfiguration",
2604 serde_json::json!({"FunctionName": "Synthetic", "MemorySize": 0}),
2605 );
2606 let validation = validate_aws_cli_command(&engine, &schema_validator, &update).expect("validation succeeds");
2607 let report = validation.report.expect("update is validated");
2608 assert!(
2609 report
2610 .diagnostics
2611 .iter()
2612 .all(|diagnostic| diagnostic.property_path.as_deref().is_some_and(|path| path.contains("MemorySize")))
2613 );
2614 let counts = &report.metadata.counts;
2615 assert_eq!(
2616 report.diagnostics.len() as u32,
2617 counts.fatal + counts.errors + counts.warnings + counts.informational + counts.debug
2618 );
2619 }
2620
2621 #[test]
2622 fn request_parameters_are_not_mutated() {
2623 let parameters = HashMap::from([
2624 ("TableName".into(), value(serde_json::json!("Synthetic"))),
2625 ("KeySchema".into(), value(serde_json::json!([{"AttributeName": "id", "KeyType": "HASH"}]))),
2626 ("AttributeDefinitions".into(), value(serde_json::json!([{"AttributeName": "id", "AttributeType": "S"}]))),
2627 ]);
2628 let original = parameters.clone();
2629 let request = AwsCliCommand::new("dynamodb", "CreateTable", parameters);
2630 let schema_validator = SchemaValidator::default();
2631 let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2632 let _ = synthesize_request(&request, &classification, &schema_validator);
2633 assert_eq!(request.parameters, original);
2634 }
2635
2636 #[test]
2637 fn json_conversion_rejects_non_json_values_without_coercion() {
2638 assert!(AwsCliValue::Bytes { value: vec![1, 2] }.to_json().is_err());
2639 assert!(AwsCliValue::Number { value: f64::NAN }.to_json().is_err());
2640 assert!(AwsCliValue::Unsupported { type_name: "timestamp".into() }.to_json().is_err());
2641 }
2642
2643 #[test]
2644 fn operation_words_preserve_acronyms_for_verb_classification() {
2645 assert_eq!(operation_words("BatchCreateDB2Cluster"), ["Batch", "Create", "DB", "2", "Cluster"]);
2646 assert_eq!(effective_verb(&operation_words("BatchCreateDB2Cluster")), "Create");
2647 }
2648
2649 #[test]
2650 fn normalize_service_changes_ascii_case_only() {
2651 assert_eq!(normalize_service("s3"), "s3");
2652 assert_eq!(normalize_service("S3"), "s3");
2653 assert_eq!(normalize_service("DynamoDb"), "dynamodb");
2654 assert_eq!(normalize_service("dynamodb"), "dynamodb");
2655 assert_eq!(normalize_service("cloud-control"), "cloud-control");
2656 assert_eq!(normalize_service("CloudControl"), "cloudcontrol");
2657 assert_eq!(normalize_service("cloudcontrolapi"), "cloudcontrolapi");
2658 }
2659
2660 #[test]
2661 fn conflicting_service_prefix_does_not_map_adapter() {
2662 let schema_validator = SchemaValidator::default();
2663 let parameters: HashMap<String, AwsCliValue> = serde_json::json!({"Bucket": "test"})
2664 .as_object()
2665 .unwrap()
2666 .iter()
2667 .map(|(k, v)| (k.clone(), AwsCliValue::from_json(v.clone())))
2668 .collect();
2669 let req = AwsCliCommand::new("ecs", "CreateBucket", parameters).with_service_prefix("s3");
2670 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2671 assert!(
2672 classification.candidates.is_empty(),
2673 "service_name=ecs with service_prefix=s3 must not map CreateBucket"
2674 );
2675
2676 let punctuated = request("s-3", "CreateBucket", serde_json::json!({"Bucket": "test"}));
2677 let classification = classify_operation(&punctuated, &schema_validator).expect("classification succeeds");
2678 assert!(classification.candidates.is_empty(), "punctuated service names must not map adapters");
2679 }
2680
2681 #[test]
2682 fn conflicting_service_prefix_does_not_validate_template_body() {
2683 let schema_validator = SchemaValidator::default();
2684 let template = br#"{"Resources":{}}"#.to_vec();
2685 let parameters: HashMap<String, AwsCliValue> =
2686 [("TemplateBody".to_string(), AwsCliValue::Bytes { value: template })].into_iter().collect();
2687 let req = AwsCliCommand::new("iot", "CreateStack", parameters).with_service_prefix("cloudformation");
2688 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2689 let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2690 assert_ne!(
2691 synthesis.source,
2692 Some(AwsCliTemplateSource::TemplateBody),
2693 "service_name=iot with service_prefix=cloudformation must not validate TemplateBody"
2694 );
2695 }
2696
2697 #[test]
2698 fn arbitrary_cloudcontrol_operation_with_valid_type_name_has_no_candidates() {
2699 let schema_validator = SchemaValidator::default();
2700 let req = request("cloudcontrol", "ListResources", serde_json::json!({"TypeName": "AWS::SNS::Topic"}));
2701 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2702 assert!(
2703 classification.candidates.is_empty(),
2704 "arbitrary cloudcontrol operations must not produce resource_types even with valid TypeName"
2705 );
2706 }
2707
2708 #[test]
2709 fn lambda_create_partial_scopes_to_mapped_properties_only() {
2710 let engine = NoopEngine::default();
2711 let schema_validator = SchemaValidator::default();
2712 let req = request("lambda", "CreateFunction", serde_json::json!({"MemorySize": 0}));
2713 let validation = validate_aws_cli_command(&engine, &schema_validator, &req).expect("validation succeeds");
2714 assert_eq!(validation.status, AwsCliCommandValidationStatus::Validated);
2715 assert_eq!(validation.template_source, Some(AwsCliTemplateSource::SynthesizedCreate));
2716 let report = validation.report.expect("create is validated");
2717 for diagnostic in &report.diagnostics {
2718 assert!(
2719 diagnostic.property_path.as_deref().is_some_and(|p| p.contains("MemorySize")),
2720 "diagnostic must be scoped to MemorySize, got: {:?}",
2721 diagnostic.property_path
2722 );
2723 }
2724 let counts = &report.metadata.counts;
2725 assert_eq!(
2726 report.diagnostics.len() as u32,
2727 counts.fatal + counts.errors + counts.warnings + counts.informational + counts.debug,
2728 );
2729 let template_bytes = validation.template.expect("validated requests carry template bytes");
2731 let template_json: serde_json::Value =
2732 serde_json::from_slice(&template_bytes).expect("template must be valid JSON");
2733 assert_eq!(template_json["Resources"]["Resource"]["Type"], "AWS::Lambda::Function");
2734 assert_eq!(template_json["Resources"]["Resource"]["Properties"]["MemorySize"], 0);
2735 }
2736
2737 #[test]
2738 fn template_body_create_operations_have_cloud_formation_create_kind() {
2739 let schema_validator = SchemaValidator::default();
2740 for operation in ["CreateChangeSet", "CreateStack", "CreateStackSet"] {
2741 let req = request("cloudformation", operation, serde_json::json!({}));
2742 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2743 assert_eq!(
2744 classification.kind,
2745 AwsCliOperationKind::CloudFormationCreate,
2746 "cloudformation:{operation} must be CloudFormationCreate"
2747 );
2748 }
2749 }
2750
2751 #[test]
2752 fn template_body_update_operations_have_cloud_formation_update_kind() {
2753 let schema_validator = SchemaValidator::default();
2754 for operation in ["UpdateStack", "UpdateStackSet"] {
2755 let req = request("cloudformation", operation, serde_json::json!({}));
2756 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2757 assert_eq!(
2758 classification.kind,
2759 AwsCliOperationKind::CloudFormationUpdate,
2760 "cloudformation:{operation} must be CloudFormationUpdate"
2761 );
2762 }
2763 }
2764
2765 #[test]
2766 fn template_body_readonly_operations_have_readonly_kind() {
2767 let schema_validator = SchemaValidator::default();
2768 for operation in ["EstimateTemplateCost", "GetTemplateSummary", "ValidateTemplate"] {
2769 let req = request("cloudformation", operation, serde_json::json!({}));
2770 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2771 assert_eq!(
2772 classification.kind,
2773 AwsCliOperationKind::ReadOnly,
2774 "cloudformation:{operation} must be ReadOnly"
2775 );
2776 }
2777 }
2778
2779 #[test]
2780 fn template_body_readonly_operations_still_validate_payload() {
2781 let engine = NoopEngine::default();
2782 let schema_validator = SchemaValidator::default();
2783 let template = br#"{"Resources":{}}"#.to_vec();
2784 for operation in ["EstimateTemplateCost", "GetTemplateSummary", "ValidateTemplate"] {
2785 let parameters: HashMap<String, AwsCliValue> =
2786 [("TemplateBody".to_string(), AwsCliValue::Bytes { value: template.clone() })].into_iter().collect();
2787 let req = AwsCliCommand::new("cloudformation", operation, parameters);
2788 let validation = validate_aws_cli_command(&engine, &schema_validator, &req).expect("validation succeeds");
2789 assert_eq!(
2790 validation.status,
2791 AwsCliCommandValidationStatus::Validated,
2792 "cloudformation:{operation} with TemplateBody must still validate"
2793 );
2794 assert_eq!(validation.template_source, Some(AwsCliTemplateSource::TemplateBody));
2795 }
2796 }
2797
2798 #[test]
2799 fn unknown_cloudformation_verbs_remain_unmapped() {
2800 let schema_validator = SchemaValidator::default();
2801 let req = request("cloudformation", "DeleteChangeSet", serde_json::json!({}));
2802 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2803 assert_eq!(classification.kind, AwsCliOperationKind::UnmappedMutation);
2804 assert!(classification.candidates.is_empty());
2805 }
2806
2807 #[test]
2808 fn unmapped_parameter_skips_synthesis_with_reason() {
2809 let schema_validator = SchemaValidator::default();
2810 let req = request("s3", "CreateBucket", serde_json::json!({"Bucket": "test-bucket", "UnknownParam": "value"}));
2811 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2812 let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2813 assert!(synthesis.template.is_none(), "unmapped parameter must skip synthesis");
2814 assert!(
2815 synthesis.reason.contains("UnknownParam") && synthesis.reason.contains("no mapping"),
2816 "reason must name the unmapped parameter: {}",
2817 synthesis.reason
2818 );
2819 }
2820
2821 #[test]
2822 fn ignored_input_does_not_block_synthesis() {
2823 let schema_validator = SchemaValidator::default();
2824 let schema = schema_validator.resource_schema_metadata("AWS::S3::Bucket").expect("S3 bucket schema must exist");
2825 let adapter = OperationAdapter {
2826 service: "s3".into(),
2827 operation: "CreateBucket".into(),
2828 phase: AdapterPhase::Create,
2829 cfn_type: "AWS::S3::Bucket".into(),
2830 mappings: vec![mapping("Bucket", "BucketName")],
2831 ignored_inputs: vec!["ClientToken".into()],
2832 };
2833 let parameters: HashMap<String, AwsCliValue> = [
2834 ("Bucket".into(), AwsCliValue::String { value: "test".into() }),
2835 ("ClientToken".into(), AwsCliValue::String { value: "idempotent-token".into() }),
2836 ]
2837 .into_iter()
2838 .collect();
2839 let result = map_adapter_properties(¶meters, &schema, &adapter).expect("mapping must succeed");
2840 match result {
2841 AdapterMappingResult::Mapped(properties) => {
2842 assert_eq!(properties.len(), 1);
2843 assert_eq!(properties["BucketName"], serde_json::json!("test"));
2844 }
2845 AdapterMappingResult::Skip(reason) => {
2846 panic!("ignored input should not skip synthesis: {reason}");
2847 }
2848 }
2849 }
2850
2851 #[test]
2852 fn update_adapter_ignores_primary_identifier_parameters() {
2853 let schema_validator = SchemaValidator::default();
2854 let schema = schema_validator
2855 .resource_schema_metadata("AWS::Lambda::Function")
2856 .expect("Lambda function schema must exist");
2857 let adapter = OperationAdapter {
2858 service: "lambda".into(),
2859 operation: "UpdateFunctionConfiguration".into(),
2860 phase: AdapterPhase::Update,
2861 cfn_type: "AWS::Lambda::Function".into(),
2862 mappings: vec![mapping("MemorySize", "MemorySize")],
2863 ignored_inputs: Vec::new(),
2864 };
2865 let parameters: HashMap<String, AwsCliValue> = [
2867 ("MemorySize".into(), AwsCliValue::Integer { value: 256 }),
2868 ("FunctionName".into(), AwsCliValue::String { value: "my-func".into() }),
2869 ]
2870 .into_iter()
2871 .collect();
2872 let result = map_adapter_properties(¶meters, &schema, &adapter).expect("mapping must succeed");
2873 match result {
2874 AdapterMappingResult::Mapped(properties) => {
2875 assert_eq!(properties.len(), 1);
2876 assert_eq!(properties["MemorySize"], serde_json::json!(256));
2877 }
2878 AdapterMappingResult::Skip(reason) => {
2879 panic!("primary identifier on update must be ignored: {reason}");
2880 }
2881 }
2882 }
2883
2884 #[test]
2885 fn all_mapped_parameters_produce_successful_synthesis() {
2886 let schema_validator = SchemaValidator::default();
2887 let req = request("s3", "CreateBucket", serde_json::json!({"Bucket": "all-mapped"}));
2888 let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2889 let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2890 assert!(synthesis.template.is_some(), "all-mapped parameters must synthesize");
2891 }
2892
2893 #[test]
2894 fn catalog_ignored_inputs_deserialize_from_missing_field() {
2895 let json = br#"{
2896 "format_version": 1,
2897 "adapters": [
2898 {"service":"test","operation":"Create","phase":"create","cfn_type":"AWS::Test::Type","mappings":[]}
2899 ]
2900 }"#;
2901 let registry = parse_adapter_registry(json).expect("catalog without ignored_inputs must parse");
2902 let adapter = registry.get(&("test".to_string(), "Create".to_string())).expect("adapter must exist");
2903 assert!(adapter.ignored_inputs.is_empty(), "missing field defaults to empty");
2904 }
2905
2906 #[test]
2907 fn catalog_ignored_inputs_deserialize_from_explicit_field() {
2908 let json = br#"{
2909 "format_version": 1,
2910 "adapters": [
2911 {"service":"test","operation":"Create","phase":"create","cfn_type":"AWS::Test::Type",
2912 "mappings":[],"ignored_inputs":["ClientToken","DryRun"]}
2913 ]
2914 }"#;
2915 let registry = parse_adapter_registry(json).expect("catalog with ignored_inputs must parse");
2916 let adapter = registry.get(&("test".to_string(), "Create".to_string())).expect("adapter must exist");
2917 assert_eq!(adapter.ignored_inputs, vec!["ClientToken", "DryRun"]);
2918 }
2919}