Skip to main content

dotenv_space/commands/
validate.rs

1/// Enhanced validation command
2///
3/// Validates .env against .env.example with comprehensive checks:
4/// - Missing/extra variables
5/// - Placeholder detection
6/// - Boolean string trap
7/// - Weak SECRET_KEY
8/// - localhost in Docker context
9/// - Multiple output formats (pretty, json, github-actions)
10use anyhow::{Context, Result};
11use colored::*;
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15
16use crate::core::{Parser, ParserConfig};
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct ValidationResult {
20    pub status: String,
21    pub required_present: usize,
22    pub required_total: usize,
23    pub issues: Vec<Issue>,
24    pub summary: Summary,
25}
26
27#[derive(Debug, Serialize, Deserialize, Clone)]
28pub struct Issue {
29    pub severity: String,
30    #[serde(rename = "type")]
31    pub issue_type: String,
32    pub variable: String,
33    pub message: String,
34    pub location: String,
35    pub suggestion: Option<String>,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39pub struct Summary {
40    pub errors: usize,
41    pub warnings: usize,
42    pub style: usize,
43}
44
45pub fn run(
46    env: String,
47    example: String,
48    strict: bool,
49    fix: bool,
50    format: String,
51    exit_zero: bool,
52    verbose: bool,
53) -> Result<()> {
54    if verbose {
55        println!("{}", "Running validate in verbose mode".dimmed());
56    }
57
58    if format == "pretty" {
59        println!(
60            "\n{}",
61            "┌─ Validating environment variables ──────────────────┐".cyan()
62        );
63        println!(
64            "{}",
65            "│ Comparing .env against .env.example                 │".cyan()
66        );
67        println!(
68            "{}\n",
69            "└──────────────────────────────────────────────────────┘".cyan()
70        );
71    }
72
73    let mut parser_config = ParserConfig::default();
74    if strict {
75        parser_config.strict = true;
76    }
77    let parser = Parser::new(parser_config);
78
79    let example_file = parser
80        .parse_file(&example)
81        .with_context(|| format!("Failed to parse {}", example))?;
82
83    let env_file = parser
84        .parse_file(&env)
85        .with_context(|| format!("Failed to parse {}", env))?;
86
87    if verbose {
88        println!(
89            "Parsed {} variables from {}",
90            example_file.vars.len(),
91            example
92        );
93        println!("Parsed {} variables from {}", env_file.vars.len(), env);
94    }
95
96    let mut issues = Vec::new();
97
98    // Check 1: Required variables present
99    let example_keys: HashSet<_> = example_file.vars.keys().collect();
100    let env_keys: HashSet<_> = env_file.vars.keys().collect();
101
102    let missing: Vec<_> = example_keys.difference(&env_keys).collect();
103    for key in &missing {
104        issues.push(Issue {
105            severity: "error".to_string(),
106            issue_type: "missing_variable".to_string(),
107            variable: key.to_string(),
108            message: format!("Missing required variable: {}", key),
109            location: format!("{}:?", env),
110            suggestion: Some(format!("Add {}=<value> to {}", key, env)),
111        });
112    }
113
114    // Check 2: Extra variables in strict mode
115    if strict {
116        let extra: Vec<_> = env_keys.difference(&example_keys).collect();
117        for key in &extra {
118            issues.push(Issue {
119                severity: "warning".to_string(),
120                issue_type: "extra_variable".to_string(),
121                variable: key.to_string(),
122                message: format!("Extra variable not in .env.example: {}", key),
123                location: format!("{}:?", env),
124                suggestion: Some(format!("Add {} to {} or remove from {}", key, example, env)),
125            });
126        }
127    }
128
129    // Check 3: Placeholder values
130    for (key, value) in &env_file.vars {
131        if is_placeholder(value) {
132            let suggestion = match key.as_str() {
133                "SECRET_KEY" => Some("Run: openssl rand -hex 32".to_string()),
134                k if k.contains("AWS") => Some("Get from AWS Console".to_string()),
135                k if k.contains("STRIPE") => Some("Get from Stripe Dashboard".to_string()),
136                _ => None,
137            };
138
139            issues.push(Issue {
140                severity: "error".to_string(),
141                issue_type: "placeholder_value".to_string(),
142                variable: key.clone(),
143                message: format!("{} looks like a placeholder", key),
144                location: format!("{}:?", env),
145                suggestion,
146            });
147        }
148    }
149
150    // Check 4: Boolean string trap
151    for (key, value) in &env_file.vars {
152        if value == "False" || value == "True" {
153            issues.push(Issue {
154                severity: "warning".to_string(),
155                issue_type: "boolean_trap".to_string(),
156                variable: key.clone(),
157                message: format!("{} is set to \"{}\" (string)", key, value),
158                location: format!("{}:?", env),
159                suggestion: Some(format!(
160                    "This is truthy in Python — use {} or 0 instead",
161                    if value == "False" { "False" } else { "True" }
162                )),
163            });
164        }
165    }
166
167    // Check 5: Weak SECRET_KEY
168    if let Some(secret_key) = env_file.vars.get("SECRET_KEY") {
169        if is_weak_secret_key(secret_key) {
170            issues.push(Issue {
171                severity: "error".to_string(),
172                issue_type: "weak_secret".to_string(),
173                variable: "SECRET_KEY".to_string(),
174                message: "SECRET_KEY is too weak".to_string(),
175                location: format!("{}:?", env),
176                suggestion: Some("Run: openssl rand -hex 32".to_string()),
177            });
178        }
179    }
180
181    // Check 6: localhost in Docker context
182    let has_docker = Path::new("docker-compose.yml").exists()
183        || Path::new("docker-compose.yaml").exists()
184        || Path::new("Dockerfile").exists();
185
186    if has_docker {
187        for (key, value) in &env_file.vars {
188            if value.contains("localhost") && (key.contains("URL") || key.contains("HOST")) {
189                issues.push(Issue {
190                    severity: "warning".to_string(),
191                    issue_type: "localhost_in_docker".to_string(),
192                    variable: key.clone(),
193                    message: format!("{} uses localhost", key),
194                    location: format!("{}:?", env),
195                    suggestion: Some(
196                        "In Docker, use service name instead (e.g., db:5432)".to_string(),
197                    ),
198                });
199            }
200        }
201    }
202
203    // Create result
204    let errors = issues.iter().filter(|i| i.severity == "error").count();
205    let warnings = issues.iter().filter(|i| i.severity == "warning").count();
206    let style = issues.iter().filter(|i| i.severity == "style").count();
207
208    let result = ValidationResult {
209        status: if errors > 0 {
210            "failed".to_string()
211        } else {
212            "passed".to_string()
213        },
214        required_present: env_file.vars.len().min(example_file.vars.len()),
215        required_total: example_file.vars.len(),
216        issues,
217        summary: Summary {
218            errors,
219            warnings,
220            style,
221        },
222    };
223
224    // Output
225    match format.as_str() {
226        "json" => output_json(&result)?,
227        "github-actions" => output_github_actions(&result)?,
228        _ => output_pretty(&result, &env_file.vars, &example_file.vars)?,
229    }
230
231    // Handle --fix flag
232    if fix && result.summary.errors > 0 {
233        println!("\n{} Auto-fix is not yet implemented", "ℹ️".cyan());
234        println!("  This will be added in a future version");
235    }
236
237    // Exit code
238    if !exit_zero && result.summary.errors > 0 {
239        std::process::exit(1);
240    }
241
242    Ok(())
243}
244
245fn output_pretty(
246    result: &ValidationResult,
247    _env_vars: &HashMap<String, String>,
248    _example_vars: &HashMap<String, String>,
249) -> Result<()> {
250    if result.issues.is_empty() {
251        println!(
252            "{} All required variables present ({}/{})",
253            "✓".green(),
254            result.required_present,
255            result.required_total
256        );
257        println!("{} No issues found", "✓".green());
258        return Ok(());
259    }
260
261    if result.required_present == result.required_total {
262        println!(
263            "{} All required variables present ({}/{})",
264            "✓".green(),
265            result.required_present,
266            result.required_total
267        );
268    } else {
269        println!(
270            "{} Missing {} required variables",
271            "✗".red(),
272            result.required_total - result.required_present
273        );
274    }
275
276    println!("{} Found {} issues\n", "✗".red(), result.issues.len());
277
278    println!("{}", "Issues:".bold());
279    for (i, issue) in result.issues.iter().enumerate() {
280        let icon = match issue.severity.as_str() {
281            "error" => "🚨",
282            "warning" => "⚠️ ",
283            _ => "ℹ️ ",
284        };
285
286        println!("  {}. {} {}", i + 1, icon, issue.message);
287        if let Some(suggestion) = &issue.suggestion {
288            println!("     → {}", suggestion.dimmed());
289        }
290        println!("     Location: {}", issue.location.dimmed());
291    }
292
293    println!("\n{}", "Summary:".bold());
294    if result.summary.errors > 0 {
295        println!("  🚨 {} critical issues", result.summary.errors);
296    }
297    if result.summary.warnings > 0 {
298        println!("  ⚠️  {} warnings", result.summary.warnings);
299    }
300    if result.summary.errors == 0 && result.summary.warnings == 0 {
301        println!("  {} 0 issues found", "✓".green());
302    }
303
304    Ok(())
305}
306
307fn output_json(result: &ValidationResult) -> Result<()> {
308    let json = serde_json::to_string_pretty(result)?;
309    println!("{}", json);
310    Ok(())
311}
312
313fn output_github_actions(result: &ValidationResult) -> Result<()> {
314    for issue in &result.issues {
315        let level = match issue.severity.as_str() {
316            "error" => "error",
317            "warning" => "warning",
318            _ => "notice",
319        };
320
321        let location = issue.location.replace(":?", "");
322
323        println!("::{}  file={},line=1::{}", level, location, issue.message);
324
325        if let Some(suggestion) = &issue.suggestion {
326            println!(
327                "::{}  file={},line=1::Suggestion: {}",
328                level, location, suggestion
329            );
330        }
331    }
332    Ok(())
333}
334
335fn is_placeholder(value: &str) -> bool {
336    let lower = value.to_lowercase();
337
338    let placeholders = [
339        "your_key_here",
340        "your_secret_here",
341        "your_token_here",
342        "change_me",
343        "changeme",
344        "replace_me",
345        "example",
346        "xxx",
347        "todo",
348        "generate-with",
349    ];
350
351    placeholders.iter().any(|p| lower.contains(p))
352}
353
354fn is_weak_secret_key(key: &str) -> bool {
355    // Too short
356    if key.len() < 32 {
357        return true;
358    }
359
360    // Common weak patterns
361    let weak = [
362        "secret", "password", "dev", "test", "1234", "abcd", "changeme",
363    ];
364
365    let lower = key.to_lowercase();
366    weak.iter().any(|w| lower.contains(w))
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn test_is_placeholder() {
375        assert!(is_placeholder("YOUR_KEY_HERE"));
376        assert!(is_placeholder("generate-with-openssl"));
377        assert!(!is_placeholder("sk_live_51Hrealkey"));
378    }
379
380    #[test]
381    fn test_is_weak_secret_key() {
382        assert!(is_weak_secret_key("short"));
383        assert!(is_weak_secret_key("this-is-a-test-secret-key-do-not-use"));
384        assert!(is_weak_secret_key("devkeysecretpassword1234567890"));
385        assert!(!is_weak_secret_key(
386            "a7b9c4d1e8f2g5h3i6j0k9l8m7n6o5p4q3r2s1t0u9v8w7x6y5z4"
387        ));
388    }
389}