From 273d8e1606a8e44d4f6b028b7715a5b0df7dcda2 Mon Sep 17 00:00:00 2001 From: Mike Garde Date: Tue, 14 Jul 2026 23:46:38 -0400 Subject: [PATCH] Add --validate flow with per-entry no_expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable standalone validation and expansion control across files - Add `-V/--validate` CLI flag to run env-file validation as a separate flow and disallow conflicting options - Introduce per-entry `no_expand` flag on `EnvValue`; default remains false and single-quoted values are treated as literals when set - Refactor parsing to collect all formatting issues (whitespace around `=` and duplicates) and return a single `EnvParseError` with the first offending line and a combined message; apply `no_expand` for single-quoted values - Centralize path resolution in `resolve_env_path` (defaults, absolute resolution, existence checks); main uses it and prints ` is valid` on success - Update error output to print only the error message and remove ad-hoc path handling in run paths - Update README: align badge to Rust branding and add a “Validating a File” section with an example (`dotenv --validate --file .env.example`) - Add tests/fixtures for validation: `space.env` and `singleQuote.env`, test valid/invalid/missing cases, exclusivity with other options, and whitespace/duplicate-key diagnostics - Improve error messaging for unterminated quotes and extend tests for quote-sensitive expansion and end-to-end fixture validation --- README.md | 10 ++- src/cli.rs | 3 + src/env_object.rs | 10 ++- src/env_parser.rs | 49 +++++++++++-- src/main.rs | 68 ++++++++++++------ tests/app.rs | 128 ++++++++++++++++++++++++++++++++- tests/envFiles/singleQuote.env | 14 ++++ tests/envFiles/space.env | 2 + tests/env_parser.rs | 55 +++++++++++++- 9 files changed, 306 insertions(+), 33 deletions(-) create mode 100644 tests/envFiles/singleQuote.env create mode 100644 tests/envFiles/space.env diff --git a/README.md b/README.md index 4853c7d..ac9cdef 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/mikegarde/dotenv-cli/test.rust.yml)](https://github.com/MikeGarde/dotenv-cli/actions) [![Codecov](https://img.shields.io/codecov/c/github/mikegarde/dotenv-cli)](https://app.codecov.io/gh/MikeGarde/dotenv-cli) [![NPM Downloads](https://img.shields.io/npm/dy/%40mikegarde%2Fdotenv-cli?logo=npm&color=blue)](https://www.npmjs.com/package/@mikegarde/dotenv-cli) -[![Crates.io Downloads](https://img.shields.io/crates/d/dotenv-cli?logo=crates&color=blue)](https://crates.io/crates/dotenv-cli) +[![Crates.io Downloads](https://img.shields.io/crates/d/dotenv-cli?logo=Rust&color=blue)](https://crates.io/crates/dotenv-cli) [![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/mikegarde/dotenv-cli/total?logo=github&color=blue)](https://github.com/MikeGarde/dotenv-cli/releases) A simple way to retrieve, update, or delete .env variables directly from the command line. @@ -76,6 +76,14 @@ Delete a value from a .env file: dotenv --delete ``` +### Validating a File + +Check that a .env file can be parsed without errors: + +```shell +dotenv --validate --file .env.example +``` + ## Examples ### RSA Key Pair diff --git a/src/cli.rs b/src/cli.rs index a739847..36bf8b8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -35,6 +35,9 @@ pub struct Cli { #[arg(short = 'D', long, action = ArgAction::SetTrue, help = "Delete the environment variable from the .env file")] pub delete: bool, + #[arg(short = 'V', long, action = ArgAction::SetTrue, help = "Validate that the .env file can be parsed without errors")] + pub validate: bool, + #[arg(long, action = ArgAction::SetTrue, help = "Exit 0 (with empty output) instead of 1 when a key is missing")] pub allow_missing: bool, diff --git a/src/env_object.rs b/src/env_object.rs index a6d05c3..4eca750 100644 --- a/src/env_object.rs +++ b/src/env_object.rs @@ -8,6 +8,7 @@ pub struct EnvValue { pub value: String, pub line_start: i64, pub line_end: i64, + pub no_expand: bool, } impl EnvValue { @@ -17,6 +18,7 @@ impl EnvValue { value, line_start: -1, line_end: -1, + no_expand: false, } } @@ -25,6 +27,7 @@ impl EnvValue { value, line_start, line_end, + no_expand: false, } } } @@ -64,7 +67,12 @@ impl EnvObject { pub fn resolve_nested_variables(&mut self) { let keys: Vec = self.entries.keys().cloned().collect(); for key in &keys { - let value = self.entries[key.as_str()].value.clone(); + let entry = &self.entries[key.as_str()]; + // Single-quoted values are literal — never expand them. + if entry.no_expand { + continue; + } + let value = entry.value.clone(); if !value.contains("${") { continue; } diff --git a/src/env_parser.rs b/src/env_parser.rs index dfeeb0a..e4102ae 100644 --- a/src/env_parser.rs +++ b/src/env_parser.rs @@ -167,6 +167,9 @@ pub fn parse_env_file(file_path: &str) -> Result { // Keep raw lines for line-number-based splice operations. let raw_lines: Vec<&str> = content.split('\n').collect(); let mut env_object = EnvObject::new(); + // Non-structural problems (bad spacing, duplicate keys) are collected rather + // than failing fast, so `--validate` can report every offending line at once. + let mut violations: Vec<(usize, String)> = Vec::new(); let mut i = 0; while i < raw_lines.len() { @@ -188,14 +191,31 @@ pub fn parse_env_file(file_path: &str) -> Result { } }; - let key = trimmed[..eq_pos].to_string(); - let value_part = trimmed[eq_pos + 1..].trim(); + let key_segment = &trimmed[..eq_pos]; + let value_segment = &trimmed[eq_pos + 1..]; + let key = key_segment.trim().to_string(); + let value_part = value_segment.trim(); if key.is_empty() { i += 1; continue; } + // Whitespace hugging the '=' (`KEY = value` or `KEY= value`) is not part + // of the key or value and is almost always a mistake. + if key_segment.ends_with(char::is_whitespace) || value_segment.starts_with(char::is_whitespace) + { + violations.push(( + line_start + 1, + format!("whitespace around '=' is not allowed: {}", raw_lines[line_start]), + )); + } + + // A key defined more than once is ambiguous. + if env_object.get(&key).is_some() { + violations.push((line_start + 1, format!("duplicate key '{}'", key))); + } + if value_part.starts_with('"') || value_part.starts_with('\'') { let quote = value_part.chars().next().unwrap(); let first_line = raw_lines[line_start]; @@ -207,10 +227,10 @@ pub fn parse_env_file(file_path: &str) -> Result { let (end, close_pos) = find_quote_close(&raw_lines, line_start, open_pos, quote)?; let value = extract_quoted_value(&raw_lines, line_start, end, open_pos, close_pos, quote)?; - env_object.set( - key, - EnvValue::with_lines(value, line_start as i64, end as i64), - ); + let mut env_value = EnvValue::with_lines(value, line_start as i64, end as i64); + // Single quotes disable ${VAR} expansion (literal, POSIX-style). + env_value.no_expand = quote == '\''; + env_object.set(key, env_value); i = end + 1; } else if value_part.starts_with('[') { let end = get_end_line(&raw_lines, line_start, "]"); @@ -237,6 +257,23 @@ pub fn parse_env_file(file_path: &str) -> Result { } } + if !violations.is_empty() { + let first_line = violations[0].0; + let message = if violations.len() == 1 { + violations[0].1.clone() + } else { + let list: String = violations + .iter() + .map(|(l, m)| format!("\n - line {}: {}", l, m)) + .collect(); + format!("found {} problems:{}", violations.len(), list) + }; + return Err(EnvParseError { + line: first_line, + message, + }); + } + env_object.resolve_nested_variables(); Ok(env_object) } diff --git a/src/main.rs b/src/main.rs index 10aeb78..8637a26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,8 +24,7 @@ fn main() { if let Some(err) = e.downcast_ref::() { eprintln!("{}", err); } else if let Some(err) = e.downcast_ref::() { - // Include "EnvParseError" in stderr so tests can detect it - eprintln!("An unexpected error occurred: EnvParseError: {}", err); + eprintln!("{}", err); } else { eprintln!("An unexpected error occurred: {}", e); } @@ -35,6 +34,30 @@ fn main() { std::process::exit(exit_code); } +fn resolve_env_path(file_arg: Option) -> Result> { + let env_file = file_arg + .or_else(|| std::env::var("DOTENV_FILE").ok()) + .unwrap_or_else(|| ".env".to_string()); + + let full_env_path: PathBuf = if Path::new(&env_file).is_absolute() { + PathBuf::from(&env_file) + } else { + std::env::current_dir() + .map_err(|e| Box::new(RuleViolationError(e.to_string())) as Box)? + .join(&env_file) + }; + let full_env_path_str = full_env_path.to_string_lossy().to_string(); + + if !full_env_path.exists() { + return Err(Box::new(RuleViolationError(format!( + "File not found: {}", + full_env_path_str + )))); + } + + Ok(full_env_path_str) +} + fn run() -> Result> { let cli = Cli::parse(); @@ -43,6 +66,25 @@ fn run() -> Result> { return Ok(0); } + if cli.validate { + if !cli.key.is_empty() || cli.set.is_some() || cli.delete || cli.json || cli.no_json { + return Err(Box::new(RuleViolationError( + "Cannot use --validate with any other options".to_string(), + ))); + } + + let full_env_path_str = resolve_env_path(cli.file.clone())?; + + if cli.debug { + eprintln!("File: {}", full_env_path_str); + } + + parse_env_file(&full_env_path_str).map_err(|e| Box::new(e) as Box)?; + + println!("{} is valid", full_env_path_str); + return Ok(0); + } + let debug = cli.debug; let multiline = cli.multiline; let delete = cli.delete; @@ -56,33 +98,13 @@ fn run() -> Result> { }; let is_set = set_value_raw.is_some(); - let env_file = cli - .file - .or_else(|| std::env::var("DOTENV_FILE").ok()) - .unwrap_or_else(|| ".env".to_string()); - - // Resolve to absolute path (mirrors Node's path.resolve) - let full_env_path: PathBuf = if Path::new(&env_file).is_absolute() { - PathBuf::from(&env_file) - } else { - std::env::current_dir() - .map_err(|e| Box::new(RuleViolationError(e.to_string())) as Box)? - .join(&env_file) - }; - let full_env_path_str = full_env_path.to_string_lossy().to_string(); + let full_env_path_str = resolve_env_path(cli.file.clone())?; if debug { eprintln!("Keys: {:?}", keys); eprintln!("File: {}", full_env_path_str); } - if !full_env_path.exists() { - return Err(Box::new(RuleViolationError(format!( - "File not found: {}", - full_env_path_str - )))); - } - let mut options = Options { full_env_path: full_env_path_str, env_object: None, diff --git a/tests/app.rs b/tests/app.rs index 3b1a220..deed14a 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -18,6 +18,13 @@ fn bad_list_path() -> String { .to_string() } +fn space_path() -> String { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + here.join("tests/envFiles/space.env") + .to_string_lossy() + .to_string() +} + #[test] fn missing_env_file() { bin() @@ -138,5 +145,124 @@ fn invalid_list_throws_error() { .arg(bad_list_path()) .assert() .failure() - .stderr(predicate::str::contains("EnvParseError")); + .stderr(predicate::str::contains("Error parsing .env file at line 1")); +} + +#[test] +fn validate_valid_file_succeeds() { + bin() + .arg("--validate") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout(predicate::str::contains("is valid")); +} + +#[test] +fn validate_short_flag_succeeds() { + bin() + .arg("-V") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout(predicate::str::contains("is valid")); +} + +#[test] +fn validate_invalid_file_fails() { + bin() + .arg("--validate") + .arg("--file") + .arg(bad_list_path()) + .assert() + .failure() + .stderr(predicate::str::contains("Error parsing .env file at line 1")); +} + +#[test] +fn validate_reports_whitespace_and_duplicate_keys() { + bin() + .arg("--validate") + .arg("--file") + .arg(space_path()) + .assert() + .failure() + .stderr(predicate::str::contains("found 3 problems")) + .stderr(predicate::str::contains( + "line 1: whitespace around '=' is not allowed", + )) + .stderr(predicate::str::contains( + "line 2: whitespace around '=' is not allowed", + )) + .stderr(predicate::str::contains("line 2: duplicate key 'SPACE'")); +} + +#[test] +fn validate_missing_file_fails() { + bin() + .arg("--validate") + .arg("--file") + .arg("non-existent.env") + .assert() + .failure() + .stderr(predicate::str::contains("File not found")); +} + +#[test] +fn validate_with_key_fails() { + bin() + .arg("--validate") + .arg("NAME") + .arg("--file") + .arg(env_path()) + .assert() + .failure() + .stderr(predicate::str::contains( + "Cannot use --validate with any other options", + )); +} + +#[test] +fn validate_with_set_fails() { + bin() + .arg("--validate") + .arg("--set") + .arg("value") + .arg("--file") + .arg(env_path()) + .assert() + .failure() + .stderr(predicate::str::contains( + "Cannot use --validate with any other options", + )); +} + +#[test] +fn validate_with_delete_fails() { + bin() + .arg("--validate") + .arg("--delete") + .arg("--file") + .arg(env_path()) + .assert() + .failure() + .stderr(predicate::str::contains( + "Cannot use --validate with any other options", + )); +} + +#[test] +fn validate_with_json_fails() { + bin() + .arg("--validate") + .arg("--json") + .arg("--file") + .arg(env_path()) + .assert() + .failure() + .stderr(predicate::str::contains( + "Cannot use --validate with any other options", + )); } diff --git a/tests/envFiles/singleQuote.env b/tests/envFiles/singleQuote.env new file mode 100644 index 0000000..e0d8a7d --- /dev/null +++ b/tests/envFiles/singleQuote.env @@ -0,0 +1,14 @@ +BASE=Hello + +# Double quotes expand ${VAR} +EXPAND_DOUBLE="${BASE} World" + +# Single quotes are literal — no expansion +LITERAL_SINGLE='${BASE} World' + +# A password containing $ stays verbatim inside single quotes, +# even when it looks like a variable reference. +PASSWORD='p${BASE}ss$w0rd$' + +# Bare $ (no braces) is never expanded, in any quoting style +BARE_DOLLAR="pa$$w0rd" diff --git a/tests/envFiles/space.env b/tests/envFiles/space.env new file mode 100644 index 0000000..2c11fd6 --- /dev/null +++ b/tests/envFiles/space.env @@ -0,0 +1,2 @@ +SPACE = Around equal is not right +SPACE= Before value diff --git a/tests/env_parser.rs b/tests/env_parser.rs index a3ee17d..23f7eb2 100644 --- a/tests/env_parser.rs +++ b/tests/env_parser.rs @@ -102,5 +102,58 @@ fn unterminated_quoted_value_fails_loudly_instead_of_swallowing_rest_of_file() { .arg(tmp.path()) .assert() .failure() - .stderr(predicate::str::contains("EnvParseError")); + .stderr(predicate::str::contains( + "Unterminated quoted value starting on line 1", + )); +} + +#[test] +fn double_quotes_expand_variables() { + let tmp = write_env("BASE=Hello\nEXPAND=\"${BASE} World\"\n"); + let json = env_json(&tmp); + assert_eq!(json["EXPAND"], "Hello World"); +} + +#[test] +fn single_quotes_disable_variable_expansion() { + let tmp = write_env("BASE=Hello\nLITERAL='${BASE} World'\n"); + let json = env_json(&tmp); + assert_eq!(json["LITERAL"], "${BASE} World"); +} + +#[test] +fn single_quotes_preserve_password_containing_dollar() { + // A password that looks like a variable reference must be stored verbatim. + let tmp = write_env("BASE=Hello\nPASSWORD='p${BASE}ss$w0rd$'\n"); + let json = env_json(&tmp); + assert_eq!(json["PASSWORD"], "p${BASE}ss$w0rd$"); +} + +#[test] +fn multiline_single_quotes_disable_expansion() { + let tmp = write_env("BASE=Hello\nLITERAL='${BASE}\nsecond line'\n"); + let json = env_json(&tmp); + assert_eq!(json["LITERAL"], "${BASE}\nsecond line"); +} + +fn single_quote_fixture() -> String { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + here.join("tests/envFiles/singleQuote.env") + .to_string_lossy() + .to_string() +} + +#[test] +fn single_quote_fixture_expansion_rules() { + let output = bin() + .arg("--file") + .arg(single_quote_fixture()) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(json["EXPAND_DOUBLE"], "Hello World"); + assert_eq!(json["LITERAL_SINGLE"], "${BASE} World"); + assert_eq!(json["PASSWORD"], "p${BASE}ss$w0rd$"); + assert_eq!(json["BARE_DOLLAR"], "pa$$w0rd"); }