Skip to main content

txtx_core/validation/
linter_rules.rs

1//! Linter-specific validation rules
2//!
3//! These rules provide additional validation beyond the basic manifest validation,
4//! including naming conventions, security checks, and production requirements.
5
6use super::manifest_validator::{
7    ManifestValidationContext, ManifestValidationRule, ValidationOutcome,
8};
9use super::rule_id::{CoreRuleId, RuleIdentifier};
10
11/// Rule: Check input naming conventions
12pub struct InputNamingConventionRule;
13
14impl ManifestValidationRule for InputNamingConventionRule {
15    fn id(&self) -> RuleIdentifier {
16        RuleIdentifier::Core(CoreRuleId::InputNamingConvention)
17    }
18
19    fn description(&self) -> &'static str {
20        "Validates that inputs follow naming conventions"
21    }
22
23    fn check(&self, ctx: &ManifestValidationContext) -> ValidationOutcome {
24        // Check for common naming issues
25        if ctx.input_name.contains('-') {
26            return ValidationOutcome::Warning {
27                message: format!(
28                    "Input '{}' contains hyphens. Consider using underscores for consistency",
29                    ctx.full_name
30                ),
31                suggestion: Some(format!("Rename to '{}'", ctx.full_name.replace('-', "_"))),
32            };
33        }
34
35        if ctx.input_name.chars().any(|c| c.is_uppercase()) {
36            return ValidationOutcome::Warning {
37                message: format!(
38                    "Input '{}' contains uppercase letters. Consider using lowercase for consistency",
39                    ctx.full_name
40                ),
41                suggestion: Some(format!(
42                    "Rename to '{}'", 
43                    ctx.full_name.to_lowercase()
44                )),
45            };
46        }
47
48        ValidationOutcome::Pass
49    }
50}
51
52/// Rule: CLI input override warnings
53pub struct CliInputOverrideRule;
54
55impl ManifestValidationRule for CliInputOverrideRule {
56    fn id(&self) -> RuleIdentifier {
57        RuleIdentifier::Core(CoreRuleId::CliInputOverride)
58    }
59
60    fn description(&self) -> &'static str {
61        "Warns when CLI inputs override environment values"
62    }
63
64    fn check(&self, ctx: &ManifestValidationContext) -> ValidationOutcome {
65        match (
66            ctx.cli_inputs.iter().find(|(k, _)| k == ctx.input_name),
67            ctx.effective_inputs.get(ctx.input_name),
68        ) {
69            (Some((_, cli_value)), Some(env_value)) if cli_value != env_value => {
70                ValidationOutcome::Warning {
71                    message: format!("CLI input '{}' overrides environment value", ctx.input_name),
72                    suggestion: Some(format!(
73                        "CLI value '{}' will be used instead of environment value '{}'",
74                        cli_value, env_value
75                    )),
76                }
77            }
78            _ => ValidationOutcome::Pass,
79        }
80    }
81}
82
83/// Rule: Sensitive data detection
84pub struct SensitiveDataRule;
85
86impl ManifestValidationRule for SensitiveDataRule {
87    fn id(&self) -> RuleIdentifier {
88        RuleIdentifier::Core(CoreRuleId::SensitiveData)
89    }
90
91    fn description(&self) -> &'static str {
92        "Detects potential sensitive data in inputs"
93    }
94
95    fn check(&self, ctx: &ManifestValidationContext) -> ValidationOutcome {
96        const SENSITIVE_PATTERNS: &[&str] = &[
97            "password",
98            "passwd",
99            "secret",
100            "token",
101            "key",
102            "credential",
103            "private",
104            "auth",
105            "apikey",
106            "api_key",
107            "access_key",
108        ];
109
110        let lower_name = ctx.input_name.to_lowercase();
111
112        if !SENSITIVE_PATTERNS.iter().any(|&p| lower_name.contains(p)) {
113            return ValidationOutcome::Pass;
114        }
115
116        let Some(value) = ctx.effective_inputs.get(ctx.input_name) else {
117            return ValidationOutcome::Pass;
118        };
119
120        if value.starts_with('<') && value.ends_with('>') {
121            return ValidationOutcome::Warning {
122                message: format!(
123                    "Input '{}' appears to contain sensitive data with placeholder value",
124                    ctx.full_name
125                ),
126                suggestion: Some("Ensure this value is properly set before deployment".to_string()),
127            };
128        }
129
130        if !value.starts_with("${") && !value.starts_with("input.") {
131            return ValidationOutcome::Warning {
132                message: format!("Input '{}' may contain hardcoded sensitive data", ctx.full_name),
133                suggestion: Some(
134                    "Consider using environment variables or secure secret management".to_string(),
135                ),
136            };
137        }
138
139        ValidationOutcome::Pass
140    }
141}
142
143/// Rule: No default values (for strict environments)
144pub struct NoDefaultValuesRule;
145
146impl ManifestValidationRule for NoDefaultValuesRule {
147    fn id(&self) -> RuleIdentifier {
148        RuleIdentifier::Core(CoreRuleId::NoDefaultValues)
149    }
150
151    fn description(&self) -> &'static str {
152        "Ensures production environments don't use default values"
153    }
154
155    fn check(&self, ctx: &ManifestValidationContext) -> ValidationOutcome {
156        // Only apply in production environments
157        if !matches!(ctx.environment, Some("production" | "prod")) {
158            return ValidationOutcome::Pass;
159        }
160
161        match (
162            ctx.manifest.environments.get("defaults").and_then(|d| d.get(ctx.input_name)),
163            ctx.effective_inputs.get(ctx.input_name),
164        ) {
165            (Some(default_value), Some(env_value)) if default_value == env_value => {
166                ValidationOutcome::Warning {
167                    message: format!(
168                        "Production environment is using default value for '{}'",
169                        ctx.full_name
170                    ),
171                    suggestion: Some(
172                        "Define an explicit value for production environment".to_string(),
173                    ),
174                }
175            }
176            _ => ValidationOutcome::Pass,
177        }
178    }
179}
180
181/// Rule: Required production inputs
182pub struct RequiredProductionInputsRule;
183
184impl ManifestValidationRule for RequiredProductionInputsRule {
185    fn id(&self) -> RuleIdentifier {
186        RuleIdentifier::Core(CoreRuleId::RequiredProductionInputs)
187    }
188
189    fn description(&self) -> &'static str {
190        "Ensures required inputs are present in production"
191    }
192
193    fn check(&self, ctx: &ManifestValidationContext) -> ValidationOutcome {
194        const REQUIRED_PATTERNS: &[&str] = &[
195            "api_url",
196            "api_endpoint",
197            "base_url",
198            "api_token",
199            "api_key",
200            "auth_token",
201            "chain_id",
202            "network_id",
203        ];
204
205        // Only apply in production environments
206        if !matches!(ctx.environment, Some("production" | "prod")) {
207            return ValidationOutcome::Pass;
208        }
209
210        let lower_name = ctx.input_name.to_lowercase();
211
212        if REQUIRED_PATTERNS.iter().any(|&p| lower_name.contains(p))
213            && !ctx.effective_inputs.contains_key(ctx.input_name)
214        {
215            ValidationOutcome::Error {
216            message: format!(
217                "Required production input '{}' is not defined",
218                ctx.full_name
219            ),
220            context: Some(
221                "Production environments must define all API endpoints and authentication tokens".to_string()
222            ),
223            suggestion: Some(
224                "Add this input to your production environment configuration".to_string()
225            ),
226            documentation_link: Some(
227                "https://docs.txtx.sh/deployment/production".to_string()
228            ),
229        }
230        } else {
231            ValidationOutcome::Pass
232        }
233    }
234}
235
236/// Get the default linter validation rules
237pub fn get_linter_rules() -> Vec<Box<dyn ManifestValidationRule>> {
238    vec![
239        Box::new(InputNamingConventionRule),
240        Box::new(CliInputOverrideRule),
241        Box::new(SensitiveDataRule),
242    ]
243}
244
245/// Get strict linter validation rules (for production)
246pub fn get_strict_linter_rules() -> Vec<Box<dyn ManifestValidationRule>> {
247    vec![
248        Box::new(InputNamingConventionRule),
249        Box::new(CliInputOverrideRule),
250        Box::new(SensitiveDataRule),
251        Box::new(NoDefaultValuesRule),
252        Box::new(RequiredProductionInputsRule),
253    ]
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::manifest::WorkspaceManifest;
260    use std::collections::{HashMap, HashSet};
261    use txtx_addon_kit::indexmap::IndexMap;
262
263    fn create_test_context<'a>(
264        input_name: &'a str,
265        full_name: &'a str,
266        manifest: &'a WorkspaceManifest,
267        effective_inputs: &'a HashMap<String, String>,
268    ) -> ManifestValidationContext<'a> {
269        ManifestValidationContext {
270            input_name,
271            full_name,
272            manifest,
273            environment: Some("production"),
274            effective_inputs,
275            cli_inputs: &[],
276            content: "",
277            file_path: "test.tx",
278            active_addons: HashSet::new(),
279        }
280    }
281
282    #[test]
283    fn test_naming_convention_rule() {
284        let manifest = WorkspaceManifest {
285            name: "test".to_string(),
286            id: "test".to_string(),
287            runbooks: vec![],
288            environments: IndexMap::new(),
289            location: None,
290        };
291
292        let inputs = HashMap::new();
293        let rule = InputNamingConventionRule;
294
295        // Test hyphenated name
296        let ctx = create_test_context("api-key", "input.api-key", &manifest, &inputs);
297        match rule.check(&ctx) {
298            ValidationOutcome::Warning { message, .. } => {
299                assert!(message.contains("hyphens"));
300            }
301            _ => panic!("Expected warning for hyphenated name"),
302        }
303
304        // Test uppercase name
305        let ctx = create_test_context("ApiKey", "input.ApiKey", &manifest, &inputs);
306        match rule.check(&ctx) {
307            ValidationOutcome::Warning { message, .. } => {
308                assert!(message.contains("uppercase"));
309            }
310            _ => panic!("Expected warning for uppercase name"),
311        }
312
313        // Test valid name
314        let ctx = create_test_context("api_key", "input.api_key", &manifest, &inputs);
315        match rule.check(&ctx) {
316            ValidationOutcome::Pass => {}
317            _ => panic!("Expected pass for valid name"),
318        }
319    }
320
321    #[test]
322    fn test_sensitive_data_rule() {
323        let manifest = WorkspaceManifest {
324            name: "test".to_string(),
325            id: "test".to_string(),
326            runbooks: vec![],
327            environments: IndexMap::new(),
328            location: None,
329        };
330
331        let mut inputs = HashMap::new();
332        inputs.insert("api_key".to_string(), "hardcoded123".to_string());
333
334        let rule = SensitiveDataRule;
335        let ctx = create_test_context("api_key", "input.api_key", &manifest, &inputs);
336
337        match rule.check(&ctx) {
338            ValidationOutcome::Warning { message, .. } => {
339                assert!(message.contains("hardcoded sensitive data"));
340            }
341            _ => panic!("Expected warning for hardcoded sensitive data"),
342        }
343    }
344}