Skip to main content

guard_translator/
lib.rs

1//! Guard DSL support shared by every rule engine.
2//!
3//! A Guard rule file is parsed once when it is loaded and evaluated by the
4//! CloudFormation Guard evaluator itself (`cloudformation-guard-lang`) against the
5//! template as the author wrote it. Every engine therefore reports exactly the
6//! checks `cfn-guard validate` reports, and none of them re-implements Guard
7//! semantics. This crate turns the evaluator's report into engine-agnostic
8//! [`GuardFinding`]s; the validation pipeline maps those onto diagnostics.
9
10use guard_lang::Status;
11use guard_lang::UnResolved;
12use guard_lang::eval::eval_rules_file;
13use guard_lang::eval_context::{
14    BinaryCheck, ClauseReport, GuardClauseReport, Messages, UnaryCheck, root_scope, simplified_json_from_root,
15};
16use guard_lang::exprs::{Block, GuardClause, Rule, RuleClause, RulesFile};
17use guard_lang::parser::{Span, rules_file};
18use guard_lang::path_value::{Path as GuardPath, PathAwareValue};
19use std::fs;
20use std::path::Path;
21use std::rc::Rc;
22
23/// A Guard rule declared in a rule file, with the metadata the rule listing shows.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct GuardRuleInfo {
26    pub name: String,
27    /// The first `<<message>>` the rule carries, which is the best available
28    /// one-line description of what the rule checks.
29    pub custom_message: Option<String>,
30}
31
32/// One failed Guard check, located by the template path the evaluator reached.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GuardFinding {
35    /// The top-level rule the failed check belongs to.
36    pub rule_name: String,
37    /// Slash-separated path from the template root to the value the check was
38    /// evaluated against (`Resources/Bucket/Properties/BucketName`), or to the
39    /// deepest value that exists when the checked property is missing. `None`
40    /// when the failure has no template location, such as a failed dependency
41    /// on another rule.
42    pub path: Option<String>,
43    /// The remainder of the query the evaluator could not resolve from `path`,
44    /// present exactly when the check failed because a property is missing.
45    pub missing_query: Option<String>,
46    /// The author's `<<message>>` for the failed check, when one was written.
47    pub custom_message: Option<String>,
48    /// The failed check as the evaluator describes it, whitespace-normalized
49    /// (`Properties.BucketName EQUALS "foo"`).
50    pub check: String,
51}
52
53/// A parsed Guard rule file, ready to be evaluated against any template.
54///
55/// The Guard parser borrows from the source text, so the file keeps the text
56/// and re-parses it per evaluation; a syntax error is still caught once, here,
57/// at load time.
58#[derive(Debug, Clone)]
59pub struct GuardRuleFile {
60    name: String,
61    pack: String,
62    source: String,
63    rules: Vec<GuardRuleInfo>,
64}
65
66impl GuardRuleFile {
67    /// Parses `source`, reporting a syntax error with the file name, and records
68    /// each declared rule's name and first custom message.
69    pub fn parse(name: impl Into<String>, source: impl Into<String>) -> Result<Self, String> {
70        let name = name.into();
71        let source = source.into();
72        let rules = match parse_rules(&source, &name)? {
73            Some(rules_file) => rules_file
74                .guard_rules
75                .iter()
76                .map(|rule| GuardRuleInfo { name: rule.rule_name.clone(), custom_message: first_custom_message(rule) })
77                .collect(),
78            None => Vec::new(),
79        };
80        let pack = pack_name_from_path(&name);
81        Ok(Self { name, pack, source, rules })
82    }
83
84    pub fn name(&self) -> &str {
85        &self.name
86    }
87
88    /// The pack the file's rules belong to, derived from the file name.
89    pub fn pack(&self) -> &str {
90        &self.pack
91    }
92
93    pub fn rules(&self) -> &[GuardRuleInfo] {
94        &self.rules
95    }
96
97    /// Evaluates every rule in the file against `template`, the authored template
98    /// as CloudFormation JSON, and returns one finding per failed check. Rules that
99    /// pass or are skipped by their `when` conditions produce nothing.
100    pub fn evaluate(&self, template: &serde_json::Value) -> Result<Vec<GuardFinding>, String> {
101        let Some(rules_file) = parse_rules(&self.source, &self.name)? else {
102            return Ok(Vec::new());
103        };
104        let root = PathAwareValue::try_from((template, GuardPath::root()))
105            .map_err(|error| format!("Failed to load the template for Guard file '{}': {}", self.name, error))?;
106        let mut scope = root_scope(&rules_file, Rc::new(root));
107        let status = eval_rules_file(&rules_file, &mut scope, Some(&self.name))
108            .map_err(|error| format!("Guard file '{}' failed to evaluate: {}", self.name, error))?;
109        if status != Status::FAIL {
110            return Ok(Vec::new());
111        }
112        let mut recorder = scope.reset_recorder();
113        let record = recorder
114            .final_event
115            .take()
116            .ok_or_else(|| format!("Guard file '{}' produced no evaluation record", self.name))?;
117        let report = simplified_json_from_root(&record).map_err(|error| {
118            format!("Guard file '{}' produced an unreadable evaluation record: {}", self.name, error)
119        })?;
120
121        let mut findings = Vec::new();
122        for failed_rule in &report.not_compliant {
123            if let ClauseReport::Rule(rule) = failed_rule {
124                for check in &rule.checks {
125                    collect_findings(check, rule.name, &mut findings);
126                }
127            }
128        }
129        Ok(findings)
130    }
131}
132
133fn parse_rules<'source>(source: &'source str, name: &'source str) -> Result<Option<RulesFile<'source>>, String> {
134    rules_file(Span::new_extra(source, name))
135        .map_err(|error| format!("Failed to parse Guard file '{}': {}", name, error))
136}
137
138fn first_custom_message(rule: &Rule<'_>) -> Option<String> {
139    rule.block.conjunctions.iter().flatten().find_map(|clause| match clause {
140        RuleClause::Clause(guard_clause) => guard_clause_custom_message(guard_clause),
141        RuleClause::WhenBlock(_, block) => block_custom_message(block),
142        RuleClause::TypeBlock(type_block) => block_custom_message(&type_block.block),
143    })
144}
145
146fn block_custom_message(block: &Block<'_, GuardClause<'_>>) -> Option<String> {
147    block.conjunctions.iter().flatten().find_map(guard_clause_custom_message)
148}
149
150fn guard_clause_custom_message(clause: &GuardClause<'_>) -> Option<String> {
151    match clause {
152        GuardClause::Clause(access) => access.access_clause.custom_message.clone(),
153        GuardClause::NamedRule(named) => named.custom_message.clone(),
154        GuardClause::ParameterizedNamedRule(parameterized) => parameterized.named_rule.custom_message.clone(),
155        GuardClause::BlockClause(block) => block_custom_message(&block.block),
156        GuardClause::WhenBlock(_, block) => block_custom_message(block),
157    }
158}
159
160/// Flattens the evaluator's report tree for one rule into findings. A group of
161/// alternatives (`A OR B`) fails as a whole, so it yields a single finding that
162/// names every alternative and is located at the first alternative that has a
163/// location; nested rule reports are attributed to the top-level rule that
164/// invoked them.
165fn collect_findings(report: &ClauseReport<'_>, rule_name: &str, findings: &mut Vec<GuardFinding>) {
166    match report {
167        ClauseReport::Rule(nested) => {
168            for check in &nested.checks {
169                collect_findings(check, rule_name, findings);
170            }
171        }
172        ClauseReport::Disjunctions(alternatives) => {
173            let mut alternative_findings = Vec::new();
174            for alternative in &alternatives.checks {
175                collect_findings(alternative, rule_name, &mut alternative_findings);
176            }
177            if let Some(merged) = merge_alternatives(alternative_findings) {
178                findings.push(merged);
179            }
180        }
181        ClauseReport::Block(block) => {
182            let (path, missing_query) = match &block.unresolved {
183                Some(unresolved) => unresolved_location(unresolved),
184                None => (None, None),
185            };
186            let check = match &block.unresolved {
187                Some(unresolved) => unresolved.remaining_query.clone(),
188                None => normalize_whitespace(&block.context),
189            };
190            findings.push(GuardFinding {
191                rule_name: rule_name.to_string(),
192                path,
193                missing_query,
194                custom_message: custom_message(&block.messages),
195                check,
196            });
197        }
198        ClauseReport::Clause(GuardClauseReport::Unary(unary)) => {
199            let (path, missing_query) = match &unary.check {
200                UnaryCheck::Resolved(comparison) => (Some(template_path(&comparison.value)), None),
201                UnaryCheck::UnResolved(unresolved) => unresolved_location(&unresolved.value),
202                UnaryCheck::UnResolvedContext(_) => (None, None),
203            };
204            findings.push(GuardFinding {
205                rule_name: rule_name.to_string(),
206                path,
207                missing_query,
208                custom_message: custom_message(&unary.messages),
209                check: normalize_whitespace(&unary.context),
210            });
211        }
212        ClauseReport::Clause(GuardClauseReport::Binary(binary)) => {
213            let (path, missing_query) = match &binary.check {
214                BinaryCheck::Resolved(comparison) => (Some(template_path(&comparison.from)), None),
215                BinaryCheck::InResolved(comparison) => (Some(template_path(&comparison.from)), None),
216                BinaryCheck::UnResolved(unresolved) => unresolved_location(&unresolved.value),
217            };
218            findings.push(GuardFinding {
219                rule_name: rule_name.to_string(),
220                path,
221                missing_query,
222                custom_message: custom_message(&binary.messages),
223                check: normalize_whitespace(&binary.context),
224            });
225        }
226    }
227}
228
229fn merge_alternatives(alternatives: Vec<GuardFinding>) -> Option<GuardFinding> {
230    let mut alternatives = alternatives.into_iter();
231    let mut merged = alternatives.next()?;
232    for alternative in alternatives {
233        if merged.path.is_none() {
234            merged.path = alternative.path;
235            merged.missing_query = alternative.missing_query;
236        }
237        if merged.custom_message.is_none() {
238            merged.custom_message = alternative.custom_message;
239        }
240        merged.check.push_str(" OR ");
241        merged.check.push_str(&alternative.check);
242    }
243    Some(merged)
244}
245
246fn unresolved_location(unresolved: &UnResolved) -> (Option<String>, Option<String>) {
247    (Some(template_path(&unresolved.traversed_to)), Some(unresolved.remaining_query.clone()))
248}
249
250/// The evaluator addresses values with a leading slash (`/Resources/Bucket`); the
251/// template root is the empty path.
252fn template_path(value: &PathAwareValue) -> String {
253    value.self_path().0.trim_start_matches('/').to_string()
254}
255
256/// The evaluator records an empty custom message when the author wrote none.
257fn custom_message(messages: &Messages) -> Option<String> {
258    messages.custom_message.as_deref().map(str::trim).filter(|message| !message.is_empty()).map(str::to_string)
259}
260
261fn normalize_whitespace(text: &str) -> String {
262    text.split_whitespace().collect::<Vec<_>>().join(" ")
263}
264
265/// Load all `.guard` files from a directory tree (recursive).
266pub fn load_guard_sources_recursive(dir: &str) -> Result<Vec<(String, String)>, String> {
267    let path = Path::new(dir);
268    if !path.is_dir() {
269        return Err(format!("Guard rule directory not found: {}", dir));
270    }
271    let mut sources = Vec::new();
272    collect_guard_files_recursive(path, &mut sources)?;
273    if sources.is_empty() {
274        return Err(format!("No .guard files found in directory (recursive): {}", dir));
275    }
276    sources.sort_by(|a, b| a.0.cmp(&b.0));
277    Ok(sources)
278}
279
280fn collect_guard_files_recursive(dir: &Path, out: &mut Vec<(String, String)>) -> Result<(), String> {
281    let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?;
282    for entry in entries {
283        let entry = entry.map_err(|e| format!("Failed to read entry in '{}': {}", dir.display(), e))?;
284        let path = entry.path();
285        if path.is_dir() {
286            collect_guard_files_recursive(&path, out)?;
287        } else if path.extension().and_then(|e| e.to_str()) == Some("guard") {
288            let path_str = path.display().to_string();
289            let content = fs::read_to_string(&path).map_err(|e| format!("Failed to read '{}': {}", path_str, e))?;
290            out.push((path_str, content));
291        }
292    }
293    Ok(())
294}
295
296/// Derive a pack name from a file path by taking the file stem and replacing
297/// non-alphanumeric characters with `_`.
298///
299/// e.g. `"security-policies/elb-listener.guard"` → `"elb_listener"`
300///
301/// The file name is taken with platform path semantics so a directory walk on Windows
302/// (`pack\elb_https.guard`) yields the same name as on Unix (`pack/elb_https.guard`).
303pub fn pack_name_from_path(path: &str) -> String {
304    let file_name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
305    let stem = file_name.trim_end_matches(".guard").trim_end_matches(".ruleset");
306    stem.chars().map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }).collect()
307}