diff --git a/src/main.rs b/src/main.rs index bea3830..7df9348 100644 --- a/src/main.rs +++ b/src/main.rs @@ -165,7 +165,8 @@ fn run() -> Result> { let env_object = parse_env_file(&options.full_env_path) .map_err(|e| Box::new(e) as Box)?; - // Wildcard expansion: `DB_*` → matched keys, automatically enables JSON output + // Wildcard expansion: `DB_*` -> matched keys, automatically enables JSON output. + // Read-only: a wildcard with --set/--delete is rejected by the qualifying rules. if keys.len() == 1 && !options.no_json && options.target_keys[0].contains('*') { if debug { eprintln!("Wildcard found"); diff --git a/src/qualifying_rules.rs b/src/qualifying_rules.rs index d9ca24e..917487f 100644 --- a/src/qualifying_rules.rs +++ b/src/qualifying_rules.rs @@ -44,5 +44,12 @@ pub fn qualifying_rules(opts: &Options) -> Result<(), RuleViolationError> { "Must specify a single key when using --delete".to_string(), )); } + // A wildcard names zero or more keys, so there is no single key to write to + // or remove. Rather than guess at which match was meant, refuse the write. + if (opts.action_set || opts.action_delete) && opts.target_keys.iter().any(|k| k.contains('*')) { + return Err(RuleViolationError( + "Cannot use a wildcard key with --set or --delete".to_string(), + )); + } Ok(()) } diff --git a/tests/wildcard.rs b/tests/wildcard.rs index b7847c5..e7bc0db 100644 --- a/tests/wildcard.rs +++ b/tests/wildcard.rs @@ -1,5 +1,8 @@ use assert_cmd::Command; +use std::fs; +use std::io::Write; use std::path::Path; +use tempfile::NamedTempFile; fn bin() -> Command { Command::cargo_bin("dotenv").unwrap() @@ -87,3 +90,55 @@ fn no_json_disables_wildcard_expansion() { .assert() .failure(); } + +const WRITABLE: &[u8] = b"# header\nDB_HOST=localhost\nAPP=1\nDB_USER=root\n"; + +fn writable_env() -> NamedTempFile { + let mut tmp = NamedTempFile::new().unwrap(); + tmp.write_all(WRITABLE).unwrap(); + tmp.flush().unwrap(); + tmp +} + +/// A wildcard names zero or more keys, so there is no single key to write to or +/// remove. Rather than guessing at which match was meant (or panicking when +/// there is none), the pattern is refused and the file is left untouched. +fn assert_write_rejected(pattern: &str, action: &[&str]) { + let tmp = writable_env(); + bin() + .arg(pattern) + .args(action) + .arg("--file") + .arg(tmp.path()) + .assert() + .code(1) + .stderr("Cannot use a wildcard key with --set or --delete\n"); + + assert_eq!( + fs::read(tmp.path()).unwrap(), + WRITABLE, + "{} {:?} must not modify the file", + pattern, + action + ); +} + +#[test] +fn wildcard_set_is_rejected() { + assert_write_rejected("DB_*", &["--set", "zzz"]); +} + +#[test] +fn wildcard_set_matching_nothing_is_rejected() { + assert_write_rejected("ZZZ_*", &["--set", "zzz"]); +} + +#[test] +fn wildcard_delete_is_rejected() { + assert_write_rejected("DB_*", &["--delete"]); +} + +#[test] +fn wildcard_delete_matching_nothing_is_rejected() { + assert_write_rejected("ZZZ_*", &["--delete"]); +}