1use crate::engine::{ExternalRuleSource, ValidationError};
10use diagnostics::{Diagnostic, Entity};
11use guard_translator::{GuardFinding, GuardRuleFile, load_guard_sources_recursive};
12use rules::{RuleMetadataEntry, RuleOrigin, Severity};
13use std::collections::HashMap;
14use std::fs;
15use std::path::Path;
16use template_model::{EntityType, SemanticModel, TopLevelSection, entity_identity, span_to_option};
17
18pub const GUARD_CATEGORY_PREFIX: &str = "guard:";
20
21const GUARD_SEVERITY: Severity = Severity::Error;
24
25#[derive(Debug, Clone)]
29pub struct GuardRuleSet {
30 files: Vec<GuardRuleFile>,
31 rule_metadata: HashMap<String, RuleMetadataEntry>,
32}
33
34impl GuardRuleSet {
35 pub fn compile(sources: &[ExternalRuleSource]) -> Result<Option<Self>, String> {
38 if sources.is_empty() {
39 return Ok(None);
40 }
41 let mut files = Vec::with_capacity(sources.len());
42 let mut rule_metadata = HashMap::new();
43 for source in sources {
44 let file = GuardRuleFile::parse(source.name.as_str(), source.content.as_str())?;
45 for rule in file.rules() {
46 rule_metadata.entry(rule.name.clone()).or_insert_with(|| RuleMetadataEntry {
47 category: Some(guard_category(file.pack())),
48 description: rule.custom_message.clone().unwrap_or_else(|| default_description(&rule.name)),
49 severity: GUARD_SEVERITY,
50 origin: RuleOrigin::Guard,
51 });
52 }
53 files.push(file);
54 }
55 Ok(Some(Self { files, rule_metadata }))
56 }
57
58 pub fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
61 &self.rule_metadata
62 }
63
64 pub fn evaluate(&self, model: &SemanticModel) -> Result<Vec<Diagnostic>, ValidationError> {
69 let template = model.authored_template_json();
70 let mut diagnostics = Vec::new();
71 for file in &self.files {
72 let findings = file.evaluate(template).map_err(ValidationError::Engine)?;
73 diagnostics.extend(findings.iter().map(|finding| guard_diagnostic(finding, file.pack(), model)));
74 }
75 Ok(diagnostics)
76 }
77}
78
79pub fn guard_category(pack: &str) -> String {
80 format!("{GUARD_CATEGORY_PREFIX}{pack}")
81}
82
83fn default_description(rule_name: &str) -> String {
84 format!("Rule {rule_name} failed")
85}
86
87fn guard_diagnostic(finding: &GuardFinding, pack: &str, model: &SemanticModel) -> Diagnostic {
91 let location = finding.path.as_deref().map(|path| FindingLocation::from_template_path(path, model));
92 Diagnostic {
93 rule_id: finding.rule_name.clone(),
94 severity: GUARD_SEVERITY,
95 message: finding_message(finding, location.as_ref()),
96 entity: location.as_ref().and_then(|location| location.entity.clone()),
97 property_path: location.as_ref().and_then(|location| location.property_path.clone()),
98 suggested_fix: None,
99 documentation_url: None,
100 category: Some(guard_category(pack)),
101 location: location
102 .and_then(|location| model.diagnostic_span(None, &location.span_key))
103 .and_then(span_to_option),
104 related_resources: None,
105 condition_scenario: None,
106 rule_description: None,
107 phase: None,
108 context: None,
109 source: RuleOrigin::Guard,
110 }
111}
112
113struct FindingLocation {
114 entity: Option<Entity>,
115 property_path: Option<String>,
118 span_key: String,
120}
121
122impl FindingLocation {
123 fn from_template_path(path: &str, model: &SemanticModel) -> Self {
124 let entity = entity_identity(path).map(|(entity_type, logical_id)| Entity {
125 logical_id: logical_id.to_string(),
126 entity_type,
127 resource_type: (entity_type == EntityType::Resource)
128 .then(|| model.resource(logical_id).map(|resource| resource.resource_type.clone()))
129 .flatten(),
130 });
131 let property_path = entity
132 .as_ref()
133 .filter(|entity| entity.entity_type == EntityType::Resource)
134 .and_then(|entity| {
135 path.strip_prefix(&format!("{}/{}/", TopLevelSection::Resources.name(), entity.logical_id))
136 })
137 .filter(|relative| !relative.is_empty())
138 .map(|relative| relative.replace('/', "."));
139 Self { entity, property_path, span_key: path.to_string() }
140 }
141}
142
143fn finding_message(finding: &GuardFinding, location: Option<&FindingLocation>) -> String {
146 if let Some(custom_message) = &finding.custom_message {
147 return custom_message.clone();
148 }
149 match &finding.missing_query {
150 Some(missing_query) => {
151 let missing_property = match location.and_then(|location| location.property_path.as_deref()) {
152 Some(property_path) => format!("{property_path}.{missing_query}"),
153 None => missing_query.clone(),
154 };
155 format!("Guard check `{}` failed: property `{}` is missing", finding.check, missing_property)
156 }
157 None => format!("Guard check `{}` failed", finding.check),
158 }
159}
160
161pub fn resolve_guard_config(rule_source_paths: &[String]) -> Result<Vec<ExternalRuleSource>, String> {
162 let mut entries = Vec::new();
163
164 for path in rule_source_paths {
165 let p = Path::new(path);
166 if p.is_dir() {
167 let sources = load_guard_sources_recursive(path)?;
168 for (file_path, file_content) in sources {
169 entries.push(ExternalRuleSource { name: file_path, content: file_content });
170 }
171 } else if p.is_file() {
172 let file_content =
173 fs::read_to_string(p).map_err(|e| format!("Failed to read guard file '{}': {}", path, e))?;
174 entries.push(ExternalRuleSource { name: path.clone(), content: file_content });
175 } else {
176 return Err(format!("Guard rule source not found: {}", path));
177 }
178 }
179
180 Ok(entries)
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use rules::Severity;
187 use std::env;
188 use std::fs;
189
190 const BUCKET_NAME_EXISTS: &str = r#"
191rule check_bucket_name {
192 AWS::S3::Bucket {
193 Properties.BucketName EXISTS
194 <<BucketName must be specified>>
195 }
196}
197"#;
198
199 const TEMPLATE_WITH_AND_WITHOUT_NAME: &str = r#"
200AWSTemplateFormatVersion: "2010-09-09"
201Resources:
202 Named:
203 Type: AWS::S3::Bucket
204 Properties:
205 BucketName: my-bucket
206 Unnamed:
207 Type: AWS::S3::Bucket
208 Properties:
209 Tags:
210 - Key: Team
211 Value: x
212"#;
213
214 fn source(name: &str, content: &str) -> ExternalRuleSource {
215 ExternalRuleSource { name: name.into(), content: content.into() }
216 }
217
218 fn model(yaml: &str) -> SemanticModel {
219 SemanticModel::from_bytes(yaml.as_bytes()).expect("template parses")
220 }
221
222 fn rule_set(sources: &[ExternalRuleSource]) -> GuardRuleSet {
223 GuardRuleSet::compile(sources).expect("guard sources compile").expect("at least one source")
224 }
225
226 #[test]
227 fn compile_returns_none_without_sources() {
228 assert!(GuardRuleSet::compile(&[]).unwrap().is_none());
229 }
230
231 #[test]
232 fn compile_rejects_a_syntax_error_naming_the_file() {
233 let error = GuardRuleSet::compile(&[source("broken.guard", "rule { nope")]).unwrap_err();
234 assert!(error.contains("broken.guard"), "got: {error}");
235 }
236
237 #[test]
238 fn compile_records_error_severity_guard_origin_pack_category_and_custom_message() {
239 let set = rule_set(&[source("policies/s3-checks.guard", BUCKET_NAME_EXISTS)]);
240 let entry = &set.rule_metadata()["check_bucket_name"];
241 assert_eq!(entry.severity, Severity::Error);
242 assert_eq!(entry.origin, RuleOrigin::Guard);
243 assert_eq!(entry.category.as_deref(), Some("guard:s3_checks"));
244 assert_eq!(entry.description, "BucketName must be specified");
245 }
246
247 #[test]
248 fn compile_describes_a_rule_without_custom_message_by_name() {
249 let set = rule_set(&[source("s3.guard", "rule bare { AWS::S3::Bucket { Properties.BucketName EXISTS } }")]);
250 assert_eq!(set.rule_metadata()["bare"].description, "Rule bare failed");
251 }
252
253 #[test]
254 fn evaluate_reports_only_the_resource_missing_the_property_with_its_location() {
255 let set = rule_set(&[source("s3.guard", BUCKET_NAME_EXISTS)]);
256 let model = model(TEMPLATE_WITH_AND_WITHOUT_NAME);
257
258 let diagnostics = set.evaluate(&model).unwrap();
259
260 assert_eq!(diagnostics.len(), 1, "the named bucket must not be reported, got: {diagnostics:?}");
261 let diagnostic = &diagnostics[0];
262 assert_eq!(diagnostic.rule_id, "check_bucket_name");
263 assert_eq!(diagnostic.severity, Severity::Error);
264 assert_eq!(diagnostic.source, RuleOrigin::Guard);
265 assert_eq!(diagnostic.category.as_deref(), Some("guard:s3"));
266 assert_eq!(diagnostic.message, "BucketName must be specified");
267 assert_eq!(diagnostic.resource_logical_id(), Some("Unnamed"));
268 assert_eq!(diagnostic.entity.as_ref().unwrap().resource_type.as_deref(), Some("AWS::S3::Bucket"));
269 assert_eq!(diagnostic.property_path.as_deref(), Some("Properties"), "located at the deepest existing node");
270 let properties_span = model.diagnostic_span(Some("Unnamed"), "Properties").unwrap();
271 assert_eq!(diagnostic.location, Some(properties_span));
272 }
273
274 #[test]
275 fn evaluate_locates_a_failed_comparison_at_the_compared_property() {
276 let set = rule_set(&[source(
277 "s3.guard",
278 r#"rule bucket_name_is_foo { AWS::S3::Bucket { Properties.BucketName == "foo" } }"#,
279 )]);
280 let model = model(TEMPLATE_WITH_AND_WITHOUT_NAME);
281
282 let diagnostics = set.evaluate(&model).unwrap();
283
284 let named = diagnostics.iter().find(|d| d.resource_logical_id() == Some("Named")).expect("Named fails");
285 assert_eq!(named.property_path.as_deref(), Some("Properties.BucketName"));
286 assert_eq!(named.location, model.diagnostic_span(Some("Named"), "Properties.BucketName"));
287 assert_eq!(named.message, r#"Guard check `Properties.BucketName EQUALS "foo"` failed"#);
288
289 let unnamed = diagnostics.iter().find(|d| d.resource_logical_id() == Some("Unnamed")).expect("Unnamed fails");
290 assert_eq!(
291 unnamed.message,
292 r#"Guard check `Properties.BucketName EQUALS "foo"` failed: property `Properties.BucketName` is missing"#
293 );
294 }
295
296 #[test]
297 fn evaluate_reports_a_finding_without_template_location_at_template_level() {
298 let set = rule_set(&[source(
299 "deps.guard",
300 r#"
301rule base { AWS::S3::Bucket { Properties.BucketName EXISTS } }
302rule dependent { base <<base must hold>> }
303"#,
304 )]);
305
306 let diagnostics = set.evaluate(&model(TEMPLATE_WITH_AND_WITHOUT_NAME)).unwrap();
307
308 let dependent = diagnostics.iter().find(|d| d.rule_id == "dependent").expect("dependent rule fails");
309 assert!(dependent.entity.is_none(), "a rule dependency failure has no entity");
310 assert_eq!(dependent.property_path, None);
311 assert_eq!(dependent.location, None);
312 assert_eq!(dependent.message, "base must hold");
313 }
314
315 #[test]
316 fn evaluate_attributes_a_parameter_finding_to_the_parameter_entity() {
317 let set = rule_set(&[source(
318 "params.guard",
319 r#"rule typed_params { Parameters.*.Type == "Number" <<parameters must be numbers>> }"#,
320 )]);
321 let model = model("Parameters:\n Env:\n Type: String\nResources:\n B:\n Type: AWS::S3::Bucket\n");
322
323 let diagnostics = set.evaluate(&model).unwrap();
324
325 assert_eq!(diagnostics.len(), 1);
326 let entity = diagnostics[0].entity.as_ref().expect("parameter entity");
327 assert_eq!(entity.entity_type, EntityType::Parameter);
328 assert_eq!(entity.logical_id, "Env");
329 assert_eq!(diagnostics[0].property_path, None, "property paths are resource-relative only");
330 assert_eq!(diagnostics[0].location, model.diagnostic_span(None, "Parameters/Env/Type"));
331 }
332
333 #[test]
334 fn evaluate_of_a_compliant_template_reports_nothing() {
335 let set = rule_set(&[source("s3.guard", BUCKET_NAME_EXISTS)]);
336 let compliant = model("Resources:\n B:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: x\n");
337 assert!(set.evaluate(&compliant).unwrap().is_empty());
338 }
339
340 #[test]
341 fn resolve_guard_config_single_file() {
342 let dir = env::temp_dir().join("guard_test_single");
343 let _ = fs::remove_dir_all(&dir);
344 fs::create_dir_all(&dir).unwrap();
345 let file = dir.join("test.guard");
346 fs::write(&file, "rule example { true }").unwrap();
347
348 let entries = resolve_guard_config(&[file.to_string_lossy().to_string()]).expect("single file resolves");
349 assert_eq!(entries.len(), 1);
350 assert!(entries[0].content.contains("rule example"));
351 assert!(entries[0].name.contains("test.guard"));
352
353 let _ = fs::remove_dir_all(&dir);
354 }
355
356 #[test]
357 fn resolve_guard_config_directory_recursive() {
358 let dir = env::temp_dir().join("guard_test_dir");
359 let _ = fs::remove_dir_all(&dir);
360 let sub = dir.join("sub");
361 fs::create_dir_all(&sub).unwrap();
362 fs::write(dir.join("a.guard"), "rule a { true }").unwrap();
363 fs::write(sub.join("b.guard"), "rule b { true }").unwrap();
364
365 let entries = resolve_guard_config(&[dir.to_string_lossy().to_string()]).expect("directory resolves");
366 assert_eq!(entries.len(), 2);
367
368 let _ = fs::remove_dir_all(&dir);
369 }
370
371 #[test]
372 fn resolve_guard_config_nonexistent_path_returns_error() {
373 let err = resolve_guard_config(&["/nonexistent/path/to/guard.guard".into()]).unwrap_err();
374 assert!(err.contains("not found"), "error should mention 'not found', got: {err}");
375 }
376
377 #[test]
378 fn resolve_guard_config_empty_paths_returns_empty() {
379 assert!(resolve_guard_config(&[]).expect("empty paths succeed").is_empty());
380 }
381
382 #[test]
383 fn resolve_guard_config_mixed_file_and_dir_preserves_content() {
384 let dir = env::temp_dir().join("guard_test_mixed");
385 let _ = fs::remove_dir_all(&dir);
386 let sub = dir.join("rules");
387 fs::create_dir_all(&sub).unwrap();
388 let standalone = dir.join("standalone.guard");
389 let guard_source = "rule check_bucket {\n AWS::S3::Bucket {\n Properties.BucketName exists\n }\n}";
390 fs::write(&standalone, guard_source).unwrap();
391 fs::write(sub.join("packed.guard"), "rule packed { true }").unwrap();
392
393 let entries =
394 resolve_guard_config(&[standalone.to_string_lossy().to_string(), sub.to_string_lossy().to_string()])
395 .expect("mixed file and dir resolves");
396 assert_eq!(entries.len(), 2);
397 assert_eq!(entries[0].content, guard_source);
398
399 let _ = fs::remove_dir_all(&dir);
400 }
401}