Skip to main content

validation_engine/
guard.rs

1//! Guard DSL rules as a validation-pipeline concern shared by every engine.
2//!
3//! [`GuardRuleSet`] holds the loaded rule files and turns the Guard evaluator's
4//! findings into [`Diagnostic`]s: the finding's template path becomes the
5//! diagnostic's entity, property path, and source span. Because both built-in
6//! engines evaluate Guard rules through this one type, they cannot disagree on a
7//! Guard finding.
8
9use 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
18/// Prefix of the category every Guard rule reports, followed by its pack name.
19pub const GUARD_CATEGORY_PREFIX: &str = "guard:";
20
21/// Every Guard finding is reported at this severity; the Guard language has no
22/// severity of its own.
23const GUARD_SEVERITY: Severity = Severity::Error;
24
25/// The Guard rule files an engine was configured with, parsed once at
26/// construction so a syntax error fails engine construction rather than every
27/// later validation.
28#[derive(Debug, Clone)]
29pub struct GuardRuleSet {
30    files: Vec<GuardRuleFile>,
31    rule_metadata: HashMap<String, RuleMetadataEntry>,
32}
33
34impl GuardRuleSet {
35    /// Parses every source, or returns `None` when there are none so an engine
36    /// can skip Guard evaluation entirely.
37    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    /// Metadata for every loaded Guard rule, keyed by rule name, in the form the
59    /// rule listing and enrichment consume.
60    pub fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
61        &self.rule_metadata
62    }
63
64    /// Evaluates every rule file against the authored template and reports one
65    /// diagnostic per failed check. An evaluation failure is an error, never a
66    /// diagnostic, because it describes a problem with the rules rather than
67    /// with the template.
68    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
87/// A finding located under `Resources/<id>/...` is attributed to that resource
88/// with a resource-relative property path; a finding under another section keeps
89/// the section's entity; a finding with no path is a template-level diagnostic.
90fn 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    /// Dotted path relative to the resource (`Properties.BucketName`); only
116    /// resource findings have one, matching every other resource diagnostic.
117    property_path: Option<String>,
118    /// The section-absolute span-index key the finding's path denotes.
119    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
143/// The author's message wins. Without one, the message names the failed check
144/// and, for a missing property, the property that could not be found.
145fn 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}