envvault/cli/commands/
export.rs1use 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
16pub 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 let secrets = store.get_all_secrets()?;
27
28 let sorted: BTreeMap<_, _> = secrets.into_iter().collect();
30
31 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 match output_path {
51 Some(dest) => {
52 let dest_path = Path::new(dest);
53
54 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 print!("{content}");
78 }
79 }
80
81 Ok(())
82}
83
84fn 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 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 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
111fn 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}