Skip to main content

envo/
commands.rs

1//! Command handlers. Each returns the process exit code to use.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command as ProcCommand;
6
7use anyhow::{bail, Context, Result};
8
9use crate::cli::{
10    CheckArgs, Cli, Command, CryptoArgs, ExportArgs, ExportFormat, HookAction, HookArgs, InitArgs,
11    ListArgs, RunArgs, ScanArgs,
12};
13use crate::envfile::{self, EnvMap};
14use crate::resolve::{self, ResolveOptions, Resolved};
15use crate::schema::Schema;
16use crate::{crypto, hook, output as out, scan, validate};
17
18pub fn dispatch(cli: Cli) -> Result<i32> {
19    let dir = match &cli.dir {
20        Some(d) => d.clone(),
21        None => std::env::current_dir().context("getting current directory")?,
22    };
23
24    match cli.command {
25        Command::Init(args) => cmd_init(&dir, args),
26        Command::Check(args) => cmd_check(&dir, args),
27        Command::List(args) => cmd_list(&dir, args),
28        Command::Run(args) => cmd_run(&dir, args),
29        Command::Export(args) => cmd_export(&dir, args),
30        Command::Encrypt(args) => cmd_encrypt(&dir, args),
31        Command::Decrypt(args) => cmd_decrypt(&dir, args),
32        Command::Scan(args) => cmd_scan(&dir, args),
33        Command::Hook(args) => cmd_hook(&dir, args),
34    }
35}
36
37// --- schema loading -------------------------------------------------------
38
39fn schema_path(dir: &Path) -> PathBuf {
40    dir.join(".envo")
41}
42
43fn load_schema(dir: &Path) -> Result<Schema> {
44    let path = schema_path(dir);
45    let text = fs::read_to_string(&path).map_err(|_| {
46        anyhow::anyhow!(
47            "no .envo schema found at {}. Run `envo init` to create one.",
48            path.display()
49        )
50    })?;
51    Schema::parse(&text).with_context(|| format!("parsing {}", path.display()))
52}
53
54fn load_schema_opt(dir: &Path) -> Result<Option<Schema>> {
55    let path = schema_path(dir);
56    if !path.exists() {
57        return Ok(None);
58    }
59    let text = fs::read_to_string(&path)?;
60    Ok(Some(
61        Schema::parse(&text).with_context(|| format!("parsing {}", path.display()))?,
62    ))
63}
64
65fn print_warnings(resolved: &Resolved) {
66    for w in &resolved.warnings {
67        eprintln!("{} {}", out::yellow("warning:"), w);
68    }
69}
70
71// --- init -----------------------------------------------------------------
72
73const SAMPLE_ENVO: &str = "\
74# .envo - typed schema for your environment variables.
75# syntax:  NAME: type [= default]   (append ? to mark a variable optional)
76# types:   string int number bool port url secret enum(a, b, c)
77
78NODE_ENV: enum(development, staging, production) = development
79PORT: port = 3000
80HOST: string = \"127.0.0.1\"
81DATABASE_URL: url
82LOG_LEVEL: enum(debug, info, warn, error) = info
83DEBUG: bool = false
84API_KEY: secret
85SENTRY_DSN: url?
86";
87
88const SAMPLE_ENV: &str = "\
89# Local values (gitignored). Committed defaults live in .envo; profile config
90# in .env.<profile>; secrets in .env.secrets (encrypt to .env.secrets.enc).
91DATABASE_URL=postgres://localhost:5432/app
92";
93
94const GITIGNORE_LINES: &[&str] = &[".env", ".env.local", "*.secrets", ".envo.key"];
95
96fn cmd_init(dir: &Path, args: InitArgs) -> Result<i32> {
97    let envo = schema_path(dir);
98    let env = dir.join(".env");
99    let mut created = Vec::new();
100
101    write_unless_exists(&envo, SAMPLE_ENVO, args.force, &mut created)?;
102    write_unless_exists(&env, SAMPLE_ENV, args.force, &mut created)?;
103    let added = ensure_gitignore(dir)?;
104
105    if created.is_empty() && added.is_empty() {
106        println!(
107            "{} nothing to do (use --force to overwrite .envo / .env)",
108            out::yellow("•")
109        );
110        return Ok(0);
111    }
112    for path in &created {
113        println!("{} created {}", out::green("✓"), rel(dir, path));
114    }
115    if !added.is_empty() {
116        println!(
117            "{} updated .gitignore ({})",
118            out::green("✓"),
119            added.join(", ")
120        );
121    }
122    println!(
123        "\nNext: edit {}, then run {}",
124        out::bold(".envo"),
125        out::bold("envo check")
126    );
127    Ok(0)
128}
129
130fn write_unless_exists(
131    path: &Path,
132    content: &str,
133    force: bool,
134    created: &mut Vec<PathBuf>,
135) -> Result<()> {
136    if path.exists() && !force {
137        return Ok(());
138    }
139    fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
140    created.push(path.to_path_buf());
141    Ok(())
142}
143
144fn ensure_gitignore(dir: &Path) -> Result<Vec<String>> {
145    let path = dir.join(".gitignore");
146    let existing = fs::read_to_string(&path).unwrap_or_default();
147    let present: Vec<&str> = existing.lines().map(|l| l.trim()).collect();
148    let mut to_add = Vec::new();
149    for line in GITIGNORE_LINES {
150        if !present.iter().any(|l| l == line) {
151            to_add.push(line.to_string());
152        }
153    }
154    if to_add.is_empty() {
155        return Ok(to_add);
156    }
157    let mut content = existing;
158    if !content.is_empty() && !content.ends_with('\n') {
159        content.push('\n');
160    }
161    content.push_str("\n# envo\n");
162    for line in &to_add {
163        content.push_str(line);
164        content.push('\n');
165    }
166    fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
167    Ok(to_add)
168}
169
170// --- check ----------------------------------------------------------------
171
172fn cmd_check(dir: &Path, args: CheckArgs) -> Result<i32> {
173    let schema = load_schema(dir)?;
174    let resolved = resolve::resolve(
175        &schema,
176        &ResolveOptions {
177            dir,
178            profile: args.profile.as_deref(),
179            include_process_env: !args.no_process_env,
180            decrypt_secrets: true,
181            key_file: args.key_file.as_deref(),
182        },
183    )?;
184    print_warnings(&resolved);
185
186    let report = validate::validate(&schema, &resolved);
187
188    for name in &report.missing {
189        let spec = schema.get(name).unwrap();
190        println!(
191            "{} {} {}",
192            out::red("✗"),
193            out::bold(name),
194            out::dim(&format!("missing (required {})", spec.ty.name())),
195        );
196    }
197    for (name, reason) in &report.invalid {
198        println!("{} {} {}", out::red("✗"), out::bold(name), out::dim(reason),);
199    }
200    if args.strict {
201        for name in &report.extra {
202            println!(
203                "{} {} {}",
204                out::yellow("!"),
205                out::bold(name),
206                out::dim("not declared in schema"),
207            );
208        }
209    } else if !report.extra.is_empty() {
210        println!(
211            "{} {} undeclared variable(s): {}",
212            out::dim("•"),
213            out::dim(&report.extra.len().to_string()),
214            out::dim(&report.extra.join(", ")),
215        );
216    }
217
218    if report.failed(args.strict) {
219        let problems = report.missing.len() + report.invalid.len();
220        println!(
221            "\n{} {} problem(s) found",
222            out::red("✗"),
223            out::bold(&problems.to_string())
224        );
225        Ok(1)
226    } else {
227        let checked = schema.vars.len();
228        println!(
229            "{} all {} variable(s) valid",
230            out::green("✓"),
231            out::bold(&checked.to_string())
232        );
233        Ok(0)
234    }
235}
236
237// --- list -----------------------------------------------------------------
238
239fn cmd_list(dir: &Path, args: ListArgs) -> Result<i32> {
240    let schema = load_schema(dir)?;
241    let resolved = resolve::resolve(
242        &schema,
243        &ResolveOptions {
244            dir,
245            profile: args.profile.as_deref(),
246            include_process_env: args.process_env,
247            decrypt_secrets: true,
248            key_file: args.key_file.as_deref(),
249        },
250    )?;
251    print_warnings(&resolved);
252
253    if args.json {
254        print_json_object(resolved.values.iter().map(|(k, v)| {
255            let display = if is_secret(&schema, k) && !args.show_secrets {
256                "<hidden>".to_string()
257            } else {
258                v.to_string()
259            };
260            (k.to_string(), display)
261        }));
262        return Ok(0);
263    }
264
265    if resolved.values.is_empty() {
266        println!("{}", out::dim("(no variables resolved)"));
267        return Ok(0);
268    }
269
270    let name_w = resolved.values.keys().map(|k| k.len()).max().unwrap_or(0);
271    for (k, v) in resolved.values.iter() {
272        let value = if is_secret(&schema, k) && !args.show_secrets {
273            out::dim("<hidden>")
274        } else {
275            v.to_string()
276        };
277        let src = resolved
278            .source_of(k)
279            .map(|s| s.label())
280            .unwrap_or("unknown");
281        println!(
282            "{:<width$}  {}  {}",
283            out::bold(k),
284            value,
285            out::dim(&format!("({src})")),
286            width = name_w + bold_pad(k),
287        );
288    }
289    Ok(0)
290}
291
292fn is_secret(schema: &Schema, name: &str) -> bool {
293    schema.get(name).map(|s| s.ty.is_secret()).unwrap_or(false)
294}
295
296/// `out::bold` wraps the key in ANSI codes (when enabled), which throws off the
297/// `{:<width}` padding. Compensate by adding the invisible byte count.
298fn bold_pad(key: &str) -> usize {
299    if out::color_enabled() {
300        out::bold(key).len() - key.len()
301    } else {
302        0
303    }
304}
305
306// --- run ------------------------------------------------------------------
307
308fn cmd_run(dir: &Path, args: RunArgs) -> Result<i32> {
309    let schema = load_schema(dir)?;
310    let resolved = resolve::resolve(
311        &schema,
312        &ResolveOptions {
313            dir,
314            profile: args.profile.as_deref(),
315            include_process_env: true,
316            decrypt_secrets: true,
317            key_file: args.key_file.as_deref(),
318        },
319    )?;
320    print_warnings(&resolved);
321
322    if !args.no_validate {
323        let report = validate::validate(&schema, &resolved);
324        if !report.passed() {
325            for name in &report.missing {
326                eprintln!("{} {} missing", out::red("✗"), out::bold(name));
327            }
328            for (name, reason) in &report.invalid {
329                eprintln!("{} {} {}", out::red("✗"), out::bold(name), out::dim(reason));
330            }
331            eprintln!(
332                "{} refusing to run with an invalid environment (use --no-validate to override)",
333                out::red("error:")
334            );
335            return Ok(1);
336        }
337    }
338
339    let (program, rest) = args
340        .cmd
341        .split_first()
342        .ok_or_else(|| anyhow::anyhow!("no command given"))?;
343
344    let mut command = ProcCommand::new(program);
345    command.args(rest).current_dir(dir);
346    for (k, v) in resolved.values.iter() {
347        command.env(k, v);
348    }
349
350    let status = command
351        .status()
352        .with_context(|| format!("failed to run `{program}`"))?;
353    Ok(status.code().unwrap_or(1))
354}
355
356// --- export ---------------------------------------------------------------
357
358fn cmd_export(dir: &Path, args: ExportArgs) -> Result<i32> {
359    let schema = load_schema(dir)?;
360    let resolved = resolve::resolve(
361        &schema,
362        &ResolveOptions {
363            dir,
364            profile: args.profile.as_deref(),
365            include_process_env: false,
366            decrypt_secrets: true,
367            key_file: args.key_file.as_deref(),
368        },
369    )?;
370    print_warnings(&resolved);
371
372    match args.format {
373        ExportFormat::Dotenv => {
374            let mut map = EnvMap::new();
375            for (k, v) in resolved.values.iter() {
376                map.set(k.to_string(), v.to_string());
377            }
378            print!("{}", envfile::to_dotenv(&map));
379        }
380        ExportFormat::Shell => {
381            for (k, v) in resolved.values.iter() {
382                println!("export {k}={}", shell_quote(v));
383            }
384        }
385        ExportFormat::Json => {
386            print_json_object(
387                resolved
388                    .values
389                    .iter()
390                    .map(|(k, v)| (k.to_string(), v.to_string())),
391            );
392        }
393    }
394    Ok(0)
395}
396
397fn shell_quote(v: &str) -> String {
398    format!("'{}'", v.replace('\'', "'\\''"))
399}
400
401// --- encrypt / decrypt ----------------------------------------------------
402
403fn cmd_encrypt(dir: &Path, args: CryptoArgs) -> Result<i32> {
404    let input = args.input.unwrap_or_else(|| dir.join(".env.secrets"));
405    if !input.exists() {
406        bail!("input file {} does not exist", input.display());
407    }
408    let output = args.output.unwrap_or_else(|| with_enc_suffix(&input));
409    let plaintext = fs::read(&input).with_context(|| format!("reading {}", input.display()))?;
410    let pass = crypto::resolve_passphrase(dir, args.key_file.as_deref())?;
411    let envelope = crypto::encrypt(&plaintext, pass.as_bytes())?;
412    fs::write(&output, envelope).with_context(|| format!("writing {}", output.display()))?;
413    println!(
414        "{} encrypted {} -> {}",
415        out::green("✓"),
416        rel(dir, &input),
417        rel(dir, &output)
418    );
419    println!(
420        "{} keep {} out of git (it holds plaintext secrets)",
421        out::dim("•"),
422        rel(dir, &input)
423    );
424    Ok(0)
425}
426
427fn cmd_decrypt(dir: &Path, args: CryptoArgs) -> Result<i32> {
428    let input = args.input.unwrap_or_else(|| dir.join(".env.secrets.enc"));
429    if !input.exists() {
430        bail!("input file {} does not exist", input.display());
431    }
432    let output = args.output.unwrap_or_else(|| strip_enc_suffix(&input));
433    let envelope =
434        fs::read_to_string(&input).with_context(|| format!("reading {}", input.display()))?;
435    let pass = crypto::resolve_passphrase(dir, args.key_file.as_deref())?;
436    let plaintext = crypto::decrypt(&envelope, pass.as_bytes())?;
437    fs::write(&output, &plaintext).with_context(|| format!("writing {}", output.display()))?;
438    println!(
439        "{} decrypted {} -> {}",
440        out::green("✓"),
441        rel(dir, &input),
442        rel(dir, &output)
443    );
444    Ok(0)
445}
446
447fn with_enc_suffix(path: &Path) -> PathBuf {
448    let mut s = path.as_os_str().to_os_string();
449    s.push(".enc");
450    PathBuf::from(s)
451}
452
453fn strip_enc_suffix(path: &Path) -> PathBuf {
454    match path.extension().and_then(|e| e.to_str()) {
455        Some("enc") => path.with_extension(""),
456        _ => with_extension_suffix(path, ".dec"),
457    }
458}
459
460fn with_extension_suffix(path: &Path, suffix: &str) -> PathBuf {
461    let mut s = path.as_os_str().to_os_string();
462    s.push(suffix);
463    PathBuf::from(s)
464}
465
466// --- scan -----------------------------------------------------------------
467
468fn cmd_scan(dir: &Path, args: ScanArgs) -> Result<i32> {
469    // Secret values from the schema make leak detection precise.
470    let secret_values = collect_secret_values(dir).unwrap_or_default();
471    let scanner = scan::Scanner::new(secret_values);
472
473    let (files, check_sensitive) = gather_scan_targets(dir, &args)?;
474    let mut findings = Vec::new();
475
476    for (label, content) in &files {
477        findings.extend(scanner.scan_text(label, content));
478    }
479    if check_sensitive {
480        for (label, _) in &files {
481            if let Some(f) = scan::sensitive_file_finding(label) {
482                findings.push(f);
483            }
484        }
485    }
486
487    if findings.is_empty() {
488        println!(
489            "{} no secrets detected in {} file(s)",
490            out::green("✓"),
491            files.len()
492        );
493        return Ok(0);
494    }
495
496    findings.sort_by(|a, b| {
497        b.severity
498            .cmp(&a.severity)
499            .then_with(|| a.file.cmp(&b.file))
500            .then_with(|| a.line.cmp(&b.line))
501    });
502
503    for f in &findings {
504        let loc = if f.line > 0 {
505            format!("{}:{}", f.file, f.line)
506        } else {
507            f.file.clone()
508        };
509        println!(
510            "{} {} {}\n    {}",
511            severity_tag(f.severity),
512            out::bold(&loc),
513            out::dim(&f.rule),
514            f.snippet,
515        );
516    }
517
518    let max = scan::max_severity(&findings).unwrap();
519    let fail = max >= scan::Severity::High || (args.strict && max >= scan::Severity::Medium);
520    println!(
521        "\n{} {} finding(s)",
522        if fail {
523            out::red("✗")
524        } else {
525            out::yellow("!")
526        },
527        out::bold(&findings.len().to_string()),
528    );
529    Ok(if fail { 1 } else { 0 })
530}
531
532fn severity_tag(sev: scan::Severity) -> String {
533    match sev {
534        scan::Severity::Critical => out::red("[critical]"),
535        scan::Severity::High => out::red("[high]"),
536        scan::Severity::Medium => out::yellow("[medium]"),
537    }
538}
539
540fn collect_secret_values(dir: &Path) -> Result<Vec<String>> {
541    let schema = match load_schema_opt(dir)? {
542        Some(s) => s,
543        None => return Ok(Vec::new()),
544    };
545    let resolved = resolve::resolve(
546        &schema,
547        &ResolveOptions {
548            dir,
549            profile: None,
550            include_process_env: false,
551            decrypt_secrets: true,
552            key_file: None,
553        },
554    )?;
555    let mut values = Vec::new();
556    for spec in &schema.vars {
557        if spec.ty.is_secret() {
558            if let Some(v) = resolved.values.get(&spec.name) {
559                values.push(v.to_string());
560            }
561        }
562    }
563    Ok(values)
564}
565
566/// Returns `(labelled file contents, whether to apply the sensitive-filename
567/// check)`. The sensitive check only applies when the files are tracked/staged
568/// (i.e. actually committed), not for ad-hoc path scans.
569fn gather_scan_targets(dir: &Path, args: &ScanArgs) -> Result<(Vec<(String, String)>, bool)> {
570    if args.staged {
571        return Ok((staged_files(dir)?, true));
572    }
573    if !args.paths.is_empty() {
574        let mut files = Vec::new();
575        for p in &args.paths {
576            collect_path(p, &mut files);
577        }
578        return Ok((files, false));
579    }
580    if let Some(files) = tracked_files(dir)? {
581        return Ok((files, true));
582    }
583    // Not a git repo: walk the directory.
584    let mut files = Vec::new();
585    walk_dir(dir, dir, &mut files);
586    Ok((files, false))
587}
588
589fn staged_files(dir: &Path) -> Result<Vec<(String, String)>> {
590    let list = git(
591        dir,
592        &["diff", "--cached", "--name-only", "--diff-filter=ACMR"],
593    )
594    .context("listing staged files (is this a git repo with staged changes?)")?;
595    let mut files = Vec::new();
596    for name in list.lines().map(str::trim).filter(|l| !l.is_empty()) {
597        if let Some(content) = git(dir, &["show", &format!(":{name}")]) {
598            if !is_binary(content.as_bytes()) {
599                files.push((name.to_string(), content));
600            }
601        }
602    }
603    Ok(files)
604}
605
606fn tracked_files(dir: &Path) -> Result<Option<Vec<(String, String)>>> {
607    let root = match git(dir, &["rev-parse", "--show-toplevel"]) {
608        Some(r) => PathBuf::from(r.trim()),
609        None => return Ok(None),
610    };
611    let list = git(dir, &["ls-files"]).unwrap_or_default();
612    let mut files = Vec::new();
613    for name in list.lines().map(str::trim).filter(|l| !l.is_empty()) {
614        let path = root.join(name);
615        if let Some(content) = read_text_file(&path) {
616            files.push((name.to_string(), content));
617        }
618    }
619    Ok(Some(files))
620}
621
622fn collect_path(path: &Path, files: &mut Vec<(String, String)>) {
623    if path.is_dir() {
624        let base = path.to_path_buf();
625        walk_dir(&base, &base, files);
626    } else if let Some(content) = read_text_file(path) {
627        files.push((path.display().to_string(), content));
628    }
629}
630
631const SKIP_DIRS: &[&str] = &[
632    ".git",
633    "target",
634    "node_modules",
635    ".next",
636    "dist",
637    "build",
638    "vendor",
639    ".venv",
640    "__pycache__",
641];
642
643fn walk_dir(base: &Path, current: &Path, files: &mut Vec<(String, String)>) {
644    let entries = match fs::read_dir(current) {
645        Ok(e) => e,
646        Err(_) => return,
647    };
648    for entry in entries.flatten() {
649        let path = entry.path();
650        let name = entry.file_name();
651        let name = name.to_string_lossy();
652        if path.is_dir() {
653            if SKIP_DIRS.contains(&name.as_ref()) {
654                continue;
655            }
656            walk_dir(base, &path, files);
657        } else if let Some(content) = read_text_file(&path) {
658            let label = path
659                .strip_prefix(base)
660                .unwrap_or(&path)
661                .display()
662                .to_string();
663            files.push((label, content));
664        }
665    }
666}
667
668fn read_text_file(path: &Path) -> Option<String> {
669    let meta = fs::metadata(path).ok()?;
670    if meta.len() > 1_048_576 {
671        return None; // skip files larger than 1 MiB
672    }
673    let bytes = fs::read(path).ok()?;
674    if is_binary(&bytes) {
675        return None;
676    }
677    Some(String::from_utf8_lossy(&bytes).into_owned())
678}
679
680fn is_binary(bytes: &[u8]) -> bool {
681    bytes.iter().take(8000).any(|&b| b == 0)
682}
683
684/// Run a git command in `dir`, returning stdout on success, `None` otherwise.
685fn git(dir: &Path, args: &[&str]) -> Option<String> {
686    let output = ProcCommand::new("git")
687        .arg("-C")
688        .arg(dir)
689        .args(args)
690        .output()
691        .ok()?;
692    if output.status.success() {
693        Some(String::from_utf8_lossy(&output.stdout).into_owned())
694    } else {
695        None
696    }
697}
698
699// --- hook -----------------------------------------------------------------
700
701fn cmd_hook(dir: &Path, args: HookArgs) -> Result<i32> {
702    match args.action {
703        HookAction::Install(install) => {
704            let path = hook::install(dir, install.with_check)?;
705            println!(
706                "{} installed pre-commit hook at {}",
707                out::green("✓"),
708                path.display()
709            );
710            if install.with_check {
711                println!(
712                    "{} hook runs: envo check && envo scan --staged",
713                    out::dim("•")
714                );
715            } else {
716                println!("{} hook runs: envo scan --staged", out::dim("•"));
717            }
718            Ok(0)
719        }
720        HookAction::Uninstall => {
721            if hook::uninstall(dir)? {
722                println!("{} removed envo pre-commit hook", out::green("✓"));
723            } else {
724                println!("{} no envo hook found", out::yellow("•"));
725            }
726            Ok(0)
727        }
728    }
729}
730
731// --- shared helpers -------------------------------------------------------
732
733fn rel(dir: &Path, path: &Path) -> String {
734    path.strip_prefix(dir).unwrap_or(path).display().to_string()
735}
736
737fn print_json_object<I: Iterator<Item = (String, String)>>(pairs: I) {
738    let items: Vec<(String, String)> = pairs.collect();
739    if items.is_empty() {
740        println!("{{}}");
741        return;
742    }
743    let mut out = String::from("{\n");
744    for (i, (k, v)) in items.iter().enumerate() {
745        out.push_str("  \"");
746        out.push_str(&json_escape(k));
747        out.push_str("\": \"");
748        out.push_str(&json_escape(v));
749        out.push('"');
750        if i + 1 < items.len() {
751            out.push(',');
752        }
753        out.push('\n');
754    }
755    out.push('}');
756    println!("{out}");
757}
758
759fn json_escape(s: &str) -> String {
760    let mut out = String::with_capacity(s.len() + 2);
761    for c in s.chars() {
762        match c {
763            '"' => out.push_str("\\\""),
764            '\\' => out.push_str("\\\\"),
765            '\n' => out.push_str("\\n"),
766            '\r' => out.push_str("\\r"),
767            '\t' => out.push_str("\\t"),
768            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
769            c => out.push(c),
770        }
771    }
772    out
773}