Skip to main content

envvault/cli/commands/
export.rs

1//! `envvault export` — export secrets in various formats.
2//!
3//! Supported formats:
4//! - `env` (default): `.env` file format (KEY=value, one per line)
5//! - `json`: JSON object { "KEY": "value", ... }
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::Path;
10
11use zeroize::Zeroize;
12
13use crate::cli::output;
14use crate::cli::{load_keyfile, prompt_password_for_vault, vault_path, Cli};
15use crate::errors::{EnvVaultError, Result};
16use crate::vault::VaultStore;
17
18/// Execute the `export` command.
19pub fn execute(cli: &Cli, format: &str, output_path: Option<&str>) -> Result<()> {
20    let path = vault_path(cli)?;
21
22    let keyfile = load_keyfile(cli)?;
23    let vault_id = path.to_string_lossy();
24    let password = prompt_password_for_vault(Some(&vault_id))?;
25    let store = VaultStore::open(&path, password.as_bytes(), keyfile.as_deref())?;
26
27    // Decrypt all secrets.
28    let secrets = store.get_all_secrets()?;
29
30    // Sort by key for deterministic output.
31    let mut sorted: BTreeMap<_, _> = secrets.into_iter().collect();
32
33    // Format the output.
34    let mut content = match format {
35        "env" => format_as_env(&sorted),
36        "json" => format_as_json(&sorted)?,
37        other => {
38            return Err(EnvVaultError::CommandFailed(format!(
39                "unknown export format '{other}' — use 'env' or 'json'"
40            )));
41        }
42    };
43
44    crate::audit::log_audit(
45        cli,
46        "export",
47        None,
48        Some(&format!("{} secrets, format: {format}", sorted.len())),
49    );
50
51    // Write to file or stdout.
52    match output_path {
53        Some(dest) => {
54            let dest_path = Path::new(dest);
55
56            // Safety: refuse to overwrite vault files.
57            if Path::new(dest)
58                .extension()
59                .is_some_and(|ext| ext.eq_ignore_ascii_case("vault"))
60            {
61                return Err(EnvVaultError::CommandFailed(
62                    "refusing to export over a .vault file".into(),
63                ));
64            }
65
66            fs::write(dest_path, &content).map_err(|e| {
67                EnvVaultError::CommandFailed(format!("failed to write export file: {e}"))
68            })?;
69
70            output::success(&format!(
71                "Exported {} secrets to {} (format: {})",
72                sorted.len(),
73                dest,
74                format
75            ));
76        }
77        None => {
78            // Write to stdout (no success message, just raw output).
79            print!("{content}");
80        }
81    }
82
83    // Zeroize plaintext secrets before returning.
84    for v in sorted.values_mut() {
85        v.zeroize();
86    }
87    content.zeroize();
88
89    Ok(())
90}
91
92/// Format secrets as `.env` file content.
93fn format_as_env(secrets: &BTreeMap<String, String>) -> String {
94    use std::fmt::Write;
95    let mut out = String::new();
96    for (key, value) in secrets {
97        // Quote values that contain spaces, special chars, or are empty.
98        if value.is_empty()
99            || value.contains(' ')
100            || value.contains('#')
101            || value.contains('"')
102            || value.contains('\'')
103            || value.contains('\n')
104            || value.contains('$')
105        {
106            // Escape inner double quotes and newlines.
107            let escaped = value
108                .replace('\\', "\\\\")
109                .replace('"', "\\\"")
110                .replace('\n', "\\n");
111            let _ = writeln!(out, "{key}=\"{escaped}\"");
112        } else {
113            let _ = writeln!(out, "{key}={value}");
114        }
115    }
116    out
117}
118
119/// Format secrets as a JSON object.
120fn format_as_json(secrets: &BTreeMap<String, String>) -> Result<String> {
121    serde_json::to_string_pretty(secrets)
122        .map_err(|e| EnvVaultError::SerializationError(format!("JSON export: {e}")))
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn format_env_simple_values() {
131        let mut secrets = BTreeMap::new();
132        secrets.insert("A".into(), "hello".into());
133        secrets.insert("B".into(), "world".into());
134
135        let output = format_as_env(&secrets);
136        assert_eq!(output, "A=hello\nB=world\n");
137    }
138
139    #[test]
140    fn format_env_quotes_values_with_spaces() {
141        let mut secrets = BTreeMap::new();
142        secrets.insert("KEY".into(), "has space".into());
143
144        let output = format_as_env(&secrets);
145        assert_eq!(output, "KEY=\"has space\"\n");
146    }
147
148    #[test]
149    fn format_env_quotes_empty_values() {
150        let mut secrets = BTreeMap::new();
151        secrets.insert("EMPTY".into(), String::new());
152
153        let output = format_as_env(&secrets);
154        assert_eq!(output, "EMPTY=\"\"\n");
155    }
156
157    #[test]
158    fn format_env_quotes_values_with_dollar() {
159        let mut secrets = BTreeMap::new();
160        secrets.insert("KEY".into(), "price$100".into());
161
162        let output = format_as_env(&secrets);
163        assert_eq!(output, "KEY=\"price$100\"\n");
164    }
165
166    #[test]
167    fn format_json_produces_valid_json() {
168        let mut secrets = BTreeMap::new();
169        secrets.insert("KEY".into(), "value".into());
170
171        let output = format_as_json(&secrets).unwrap();
172        let parsed: BTreeMap<String, String> = serde_json::from_str(&output).unwrap();
173        assert_eq!(parsed["KEY"], "value");
174    }
175}