envvault/cli/commands/
export.rs1use 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
18pub 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 let secrets = store.get_all_secrets()?;
29
30 let mut sorted: BTreeMap<_, _> = secrets.into_iter().collect();
32
33 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 match output_path {
53 Some(dest) => {
54 let dest_path = Path::new(dest);
55
56 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 print!("{content}");
80 }
81 }
82
83 for v in sorted.values_mut() {
85 v.zeroize();
86 }
87 content.zeroize();
88
89 Ok(())
90}
91
92fn 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 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 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
119fn 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}