1use 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
37fn 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
71const 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
170fn 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
237fn 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
296fn bold_pad(key: &str) -> usize {
299 if out::color_enabled() {
300 out::bold(key).len() - key.len()
301 } else {
302 0
303 }
304}
305
306fn 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
356fn 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
401fn 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
466fn cmd_scan(dir: &Path, args: ScanArgs) -> Result<i32> {
469 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
566fn 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 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; }
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
684fn 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
699fn 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
731fn 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}