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