Skip to main content

envvault/cli/commands/
diff.rs

1//! `envvault diff` — compare secrets between two environments.
2//!
3//! Usage:
4//!   envvault diff staging              # compare dev (default) vs staging
5//!   envvault --env prod diff staging --show-values
6
7use std::collections::BTreeSet;
8
9use zeroize::Zeroize;
10
11use crate::cli::output;
12use crate::cli::{load_keyfile, prompt_password_for_vault, Cli};
13use crate::errors::{EnvVaultError, Result};
14use crate::vault::VaultStore;
15
16/// Outcome of comparing two vaults.
17pub struct DiffResult {
18    pub added: Vec<String>,
19    pub removed: Vec<String>,
20    pub changed: Vec<String>,
21    pub unchanged: Vec<String>,
22}
23
24/// Execute the `diff` command.
25pub fn execute(cli: &Cli, target_env: &str, show_values: bool) -> Result<()> {
26    let cwd = std::env::current_dir()?;
27    let vault_dir = cwd.join(&cli.vault_dir);
28
29    let env = &cli.env;
30    let source_path = vault_dir.join(format!("{env}.vault"));
31    let target_path = vault_dir.join(format!("{target_env}.vault"));
32
33    if !source_path.exists() {
34        return Err(EnvVaultError::EnvironmentNotFound(cli.env.clone()));
35    }
36    if !target_path.exists() {
37        return Err(EnvVaultError::EnvironmentNotFound(target_env.to_string()));
38    }
39
40    // Open source vault.
41    let keyfile = load_keyfile(cli)?;
42    let vault_id = source_path.to_string_lossy();
43    let password = prompt_password_for_vault(Some(&vault_id))?;
44    let source = VaultStore::open(&source_path, password.as_bytes(), keyfile.as_deref())?;
45    let mut source_secrets = source.get_all_secrets()?;
46
47    // Try opening target with the same password first.
48    let mut target_secrets =
49        match VaultStore::open(&target_path, password.as_bytes(), keyfile.as_deref()) {
50            Ok(target) => target.get_all_secrets()?,
51            Err(EnvVaultError::HmacMismatch | EnvVaultError::DecryptionFailed) => {
52                // Different password — prompt for target.
53                output::info(&format!(
54                    "Target vault '{target_env}' uses a different password."
55                ));
56                let target_vault_id = target_path.to_string_lossy();
57                let target_pw = prompt_password_for_vault(Some(&target_vault_id))?;
58                let target =
59                    VaultStore::open(&target_path, target_pw.as_bytes(), keyfile.as_deref())?;
60                target.get_all_secrets()?
61            }
62            Err(e) => return Err(e),
63        };
64
65    // Compute diff.
66    let diff = compute_diff(&source_secrets, &target_secrets);
67
68    crate::audit::log_audit(
69        cli,
70        "diff",
71        None,
72        Some(&format!("compared {env} vs {target_env}")),
73    );
74
75    // Print results.
76    print_diff(
77        cli,
78        target_env,
79        &diff,
80        &source_secrets,
81        &target_secrets,
82        show_values,
83    );
84
85    // Zeroize plaintext secrets before returning.
86    for v in source_secrets.values_mut() {
87        v.zeroize();
88    }
89    for v in target_secrets.values_mut() {
90        v.zeroize();
91    }
92
93    Ok(())
94}
95
96/// Compare two secret maps and categorize keys.
97pub fn compute_diff(
98    source: &std::collections::HashMap<String, String>,
99    target: &std::collections::HashMap<String, String>,
100) -> DiffResult {
101    let source_keys: BTreeSet<&String> = source.keys().collect();
102    let target_keys: BTreeSet<&String> = target.keys().collect();
103
104    // Keys only in target = added (already sorted by BTreeSet).
105    let added: Vec<String> = target_keys
106        .difference(&source_keys)
107        .map(|k| (*k).clone())
108        .collect();
109
110    // Keys only in source = removed (already sorted by BTreeSet).
111    let removed: Vec<String> = source_keys
112        .difference(&target_keys)
113        .map(|k| (*k).clone())
114        .collect();
115
116    // Keys in both — partition into changed vs unchanged.
117    let (mut changed, mut unchanged): (Vec<String>, Vec<String>) = source_keys
118        .intersection(&target_keys)
119        .map(|k| (*k).clone())
120        .partition(|key| source[key] != target[key]);
121
122    changed.sort();
123    unchanged.sort();
124
125    DiffResult {
126        added,
127        removed,
128        changed,
129        unchanged,
130    }
131}
132
133/// Print the diff results with colored output.
134fn print_diff(
135    cli: &Cli,
136    target_env: &str,
137    diff: &DiffResult,
138    source: &std::collections::HashMap<String, String>,
139    target: &std::collections::HashMap<String, String>,
140    show_values: bool,
141) {
142    use console::style;
143
144    println!(
145        "\n{} {} vs {}",
146        style("Diff:").bold(),
147        style(&cli.env).cyan(),
148        style(target_env).cyan()
149    );
150    println!();
151
152    for key in &diff.added {
153        if show_values {
154            println!(
155                "  {} {} = {}",
156                style("+").green().bold(),
157                style(key).green(),
158                style(&target[key]).green()
159            );
160        } else {
161            println!("  {} {}", style("+").green().bold(), style(key).green());
162        }
163    }
164
165    for key in &diff.removed {
166        if show_values {
167            println!(
168                "  {} {} = {}",
169                style("-").red().bold(),
170                style(key).red(),
171                style(&source[key]).red()
172            );
173        } else {
174            println!("  {} {}", style("-").red().bold(), style(key).red());
175        }
176    }
177
178    for key in &diff.changed {
179        if show_values {
180            println!(
181                "  {} {} = {} → {}",
182                style("~").yellow().bold(),
183                style(key).yellow(),
184                style(&source[key]).red(),
185                style(&target[key]).green()
186            );
187        } else {
188            println!(
189                "  {} {} {}",
190                style("~").yellow().bold(),
191                style(key).yellow(),
192                style("(changed)").dim()
193            );
194        }
195    }
196
197    println!();
198    println!(
199        "  {} added, {} removed, {} changed, {} unchanged",
200        style(diff.added.len()).green().bold(),
201        style(diff.removed.len()).red().bold(),
202        style(diff.changed.len()).yellow().bold(),
203        style(diff.unchanged.len()).dim()
204    );
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use std::collections::HashMap;
211
212    #[test]
213    fn diff_identical_vaults() {
214        let mut a = HashMap::new();
215        a.insert("KEY".into(), "value".into());
216
217        let diff = compute_diff(&a, &a);
218        assert!(diff.added.is_empty());
219        assert!(diff.removed.is_empty());
220        assert!(diff.changed.is_empty());
221        assert_eq!(diff.unchanged, vec!["KEY"]);
222    }
223
224    #[test]
225    fn diff_added_keys() {
226        let a = HashMap::new();
227        let mut b = HashMap::new();
228        b.insert("NEW_KEY".into(), "value".into());
229
230        let diff = compute_diff(&a, &b);
231        assert_eq!(diff.added, vec!["NEW_KEY"]);
232        assert!(diff.removed.is_empty());
233        assert!(diff.changed.is_empty());
234    }
235
236    #[test]
237    fn diff_removed_keys() {
238        let mut a = HashMap::new();
239        a.insert("OLD_KEY".into(), "value".into());
240        let b = HashMap::new();
241
242        let diff = compute_diff(&a, &b);
243        assert!(diff.added.is_empty());
244        assert_eq!(diff.removed, vec!["OLD_KEY"]);
245        assert!(diff.changed.is_empty());
246    }
247
248    #[test]
249    fn diff_changed_values() {
250        let mut a = HashMap::new();
251        a.insert("KEY".into(), "old_value".into());
252        let mut b = HashMap::new();
253        b.insert("KEY".into(), "new_value".into());
254
255        let diff = compute_diff(&a, &b);
256        assert!(diff.added.is_empty());
257        assert!(diff.removed.is_empty());
258        assert_eq!(diff.changed, vec!["KEY"]);
259        assert!(diff.unchanged.is_empty());
260    }
261
262    #[test]
263    fn diff_mixed_changes() {
264        let mut source = HashMap::new();
265        source.insert("KEEP".into(), "same".into());
266        source.insert("MODIFY".into(), "old".into());
267        source.insert("REMOVE".into(), "gone".into());
268
269        let mut target = HashMap::new();
270        target.insert("KEEP".into(), "same".into());
271        target.insert("MODIFY".into(), "new".into());
272        target.insert("ADD".into(), "fresh".into());
273
274        let diff = compute_diff(&source, &target);
275        assert_eq!(diff.added, vec!["ADD"]);
276        assert_eq!(diff.removed, vec!["REMOVE"]);
277        assert_eq!(diff.changed, vec!["MODIFY"]);
278        assert_eq!(diff.unchanged, vec!["KEEP"]);
279    }
280
281    #[test]
282    fn diff_empty_vaults() {
283        let a: HashMap<String, String> = HashMap::new();
284        let b: HashMap<String, String> = HashMap::new();
285
286        let diff = compute_diff(&a, &b);
287        assert!(diff.added.is_empty());
288        assert!(diff.removed.is_empty());
289        assert!(diff.changed.is_empty());
290        assert!(diff.unchanged.is_empty());
291    }
292
293    #[test]
294    fn diff_results_are_sorted() {
295        let mut source = HashMap::new();
296        source.insert("Z_KEY".into(), "v".into());
297        source.insert("A_KEY".into(), "v".into());
298
299        let mut target = HashMap::new();
300        target.insert("M_KEY".into(), "v".into());
301        target.insert("B_KEY".into(), "v".into());
302
303        let diff = compute_diff(&source, &target);
304        assert_eq!(diff.added, vec!["B_KEY", "M_KEY"]);
305        assert_eq!(diff.removed, vec!["A_KEY", "Z_KEY"]);
306    }
307
308    #[test]
309    fn diff_same_key_same_value_is_unchanged() {
310        let mut a = HashMap::new();
311        a.insert("DB_URL".into(), "postgres://localhost".into());
312        let mut b = HashMap::new();
313        b.insert("DB_URL".into(), "postgres://localhost".into());
314
315        let diff = compute_diff(&a, &b);
316        assert!(diff.changed.is_empty());
317        assert_eq!(diff.unchanged, vec!["DB_URL"]);
318    }
319}