1use anyhow::Context;
2use zeroize::Zeroize;
3
4use crate::cli::ExportFormat;
5use crate::ipc::{connect, fetch_multi_profile_secrets, resolve_profile_specs};
6
7pub(crate) fn secret_key_to_env_var(key: &str, prefix: Option<&str>) -> String {
12 let var: String = key
13 .chars()
14 .map(|c| match c {
15 '-' | '.' => '_',
16 c if c.is_ascii_alphanumeric() || c == '_' => c.to_ascii_uppercase(),
17 _ => '_',
18 })
19 .collect();
20
21 match prefix {
22 Some(p) => format!("{p}_{var}"),
23 None => var,
24 }
25}
26
27pub(crate) async fn cmd_env(
28 profile: Option<&str>,
29 prefix: Option<&str>,
30 command: &[String],
31) -> anyhow::Result<()> {
32 if command.is_empty() {
33 anyhow::bail!("no command specified");
34 }
35
36 if command.first().is_some_and(|c| c.starts_with('-')) {
37 eprintln!("hint: use '--' to separate sesame options from the command, e.g.:");
38 eprintln!(" sesame env -p default -- {}", command.join(" "));
39 }
40
41 let specs = resolve_profile_specs(profile);
42 let client = connect().await?;
43 let env_vars = fetch_multi_profile_secrets(&client, &specs, prefix).await?;
44
45 let mut cmd = std::process::Command::new(&command[0]);
47 cmd.args(&command[1..]);
48
49 let profiles_csv: String = specs
51 .iter()
52 .map(|s| match &s.org {
53 Some(org) => format!("{org}:{}", s.vault),
54 None => s.vault.clone(),
55 })
56 .collect::<Vec<_>>()
57 .join(",");
58 cmd.env("SESAME_PROFILES", &profiles_csv);
59
60 for (env_name, value) in &env_vars {
62 let val_str = String::from_utf8_lossy(value);
63 cmd.env(env_name, val_str.as_ref());
64 }
65
66 let mut child = cmd.spawn().context("failed to spawn command")?;
67
68 let status = child.wait().context("failed to wait for child process")?;
69
70 for (_, mut value) in env_vars {
71 value.zeroize();
72 }
73
74 std::process::exit(status.code().unwrap_or(1));
75}
76
77const DENIED_ENV_VARS: &[&str] = &[
80 "LD_PRELOAD",
82 "LD_LIBRARY_PATH",
83 "LD_AUDIT",
84 "LD_DEBUG",
85 "LD_DEBUG_OUTPUT",
86 "LD_DYNAMIC_WEAK",
87 "LD_PROFILE",
88 "LD_SHOW_AUXV",
89 "LD_BIND_NOW",
90 "LD_BIND_NOT",
91 "DYLD_INSERT_LIBRARIES",
92 "DYLD_LIBRARY_PATH",
93 "DYLD_FRAMEWORK_PATH",
94 "PATH",
96 "HOME",
97 "USER",
98 "SHELL",
99 "LOGNAME",
100 "LANG",
101 "TERM",
102 "DISPLAY",
103 "WAYLAND_DISPLAY",
104 "XDG_RUNTIME_DIR",
105 "BASH_ENV",
107 "ENV",
108 "BASH_FUNC_",
109 "CDPATH",
110 "GLOBIGNORE",
111 "SHELLOPTS",
112 "BASHOPTS",
113 "PROMPT_COMMAND",
114 "PS1",
115 "PS2",
116 "PS4",
117 "MAIL",
118 "MAILPATH",
119 "MAILCHECK",
120 "IFS",
121 "PYTHONPATH",
123 "PYTHONSTARTUP",
124 "PYTHONHOME",
125 "NODE_OPTIONS",
126 "NODE_PATH",
127 "NODE_EXTRA_CA_CERTS",
128 "PERL5LIB",
129 "PERL5OPT",
130 "RUBYLIB",
131 "RUBYOPT",
132 "GOPATH",
133 "GOROOT",
134 "GOFLAGS",
135 "JAVA_HOME",
136 "CLASSPATH",
137 "JAVA_TOOL_OPTIONS",
138 "SSH_AUTH_SOCK",
140 "GPG_AGENT_INFO",
141 "KRB5_CONFIG",
142 "KRB5CCNAME",
143 "SSL_CERT_FILE",
144 "SSL_CERT_DIR",
145 "CURL_CA_BUNDLE",
146 "REQUESTS_CA_BUNDLE",
147 "GIT_SSL_CAINFO",
148 "NIX_SSL_CERT_FILE",
149 "NIX_PATH",
151 "NIX_CONF_DIR",
152 "SUDO_ASKPASS",
154 "SUDO_EDITOR",
155 "VISUAL",
156 "EDITOR",
157 "SYSTEMD_UNIT_PATH",
159 "DBUS_SESSION_BUS_ADDRESS",
160 "SESAME_PROFILE",
162];
163
164pub(crate) fn is_denied_env_var(name: &str) -> bool {
166 if name.starts_with("BASH_FUNC_") {
167 return true;
168 }
169 DENIED_ENV_VARS
170 .iter()
171 .any(|&d| d.eq_ignore_ascii_case(name))
172}
173
174pub(crate) fn shell_escape(s: &str) -> String {
177 let mut out = String::with_capacity(s.len() + 8);
178 for c in s.chars() {
179 match c {
180 '\0' => {} '"' | '\\' | '$' | '`' | '!' => {
182 out.push('\\');
183 out.push(c);
184 }
185 '\n' => out.push_str("\\n"),
186 '\r' => out.push_str("\\r"),
187 _ => out.push(c),
188 }
189 }
190 out
191}
192
193pub(crate) fn json_escape(s: &str) -> String {
195 s.chars()
196 .map(|c| match c {
197 '\0' => String::new(),
198 '"' => "\\\"".to_string(),
199 '\\' => "\\\\".to_string(),
200 '\n' => "\\n".to_string(),
201 '\r' => "\\r".to_string(),
202 '\t' => "\\t".to_string(),
203 c if c.is_control() => format!("\\u{:04x}", c as u32),
204 c => c.to_string(),
205 })
206 .collect()
207}
208
209pub(crate) async fn cmd_export(
210 profile: Option<&str>,
211 format: &ExportFormat,
212 prefix: Option<&str>,
213) -> anyhow::Result<()> {
214 let specs = resolve_profile_specs(profile);
215 let client = connect().await?;
216 let raw_secrets = fetch_multi_profile_secrets(&client, &specs, prefix).await?;
217 if raw_secrets.is_empty() {
218 return Ok(());
219 }
220
221 let entries: Vec<(String, String)> = raw_secrets
223 .into_iter()
224 .map(|(k, v)| {
225 let val_str = String::from_utf8_lossy(&v).into_owned();
226 (k, val_str)
227 })
228 .collect();
229
230 match format {
232 ExportFormat::Shell => {
233 for (k, v) in &entries {
234 println!("export {}=\"{}\"", k, shell_escape(v));
235 }
236 }
237 ExportFormat::Dotenv => {
238 for (k, v) in &entries {
239 println!("{}=\"{}\"", k, shell_escape(v));
240 }
241 }
242 ExportFormat::Json => {
243 print!("{{");
244 for (i, (k, v)) in entries.iter().enumerate() {
245 if i > 0 {
246 print!(",");
247 }
248 print!("\"{}\":\"{}\"", json_escape(k), json_escape(v));
249 }
250 println!("}}");
251 }
252 }
253
254 for (_, mut v) in entries {
256 unsafe {
257 v.as_bytes_mut().zeroize();
258 }
259 }
260
261 Ok(())
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn shell_escape_plain() {
270 assert_eq!(shell_escape("hello"), "hello");
271 }
272
273 #[test]
274 fn shell_escape_special_chars() {
275 assert_eq!(shell_escape(r#"a"b$c`d\e!f"#), r#"a\"b\$c\`d\\e\!f"#);
276 }
277
278 #[test]
279 fn shell_escape_newlines() {
280 assert_eq!(shell_escape("line1\nline2\r"), "line1\\nline2\\r");
281 }
282
283 #[test]
284 fn secret_name_to_env_var_basic() {
285 assert_eq!(secret_key_to_env_var("api-key", None), "API_KEY");
286 }
287
288 #[test]
289 fn secret_name_to_env_var_with_prefix() {
290 assert_eq!(
291 secret_key_to_env_var("api-key", Some("MYAPP")),
292 "MYAPP_API_KEY"
293 );
294 }
295
296 #[test]
297 fn secret_name_to_env_var_dots_and_mixed() {
298 assert_eq!(secret_key_to_env_var("db.host-name", None), "DB_HOST_NAME");
299 }
300
301 #[test]
302 fn shell_escape_strips_null_bytes() {
303 assert_eq!(shell_escape("before\0after"), "beforeafter");
304 }
305
306 #[test]
307 fn denied_env_var_ld_preload() {
308 assert!(is_denied_env_var("LD_PRELOAD"));
309 }
310
311 #[test]
312 fn denied_env_var_path() {
313 assert!(is_denied_env_var("PATH"));
314 }
315
316 #[test]
317 fn denied_env_var_case_insensitive() {
318 assert!(is_denied_env_var("ld_preload"));
319 assert!(is_denied_env_var("Path"));
320 }
321
322 #[test]
323 fn denied_env_var_bash_func_prefix() {
324 assert!(is_denied_env_var("BASH_FUNC_evil%%"));
325 }
326
327 #[test]
328 fn denied_env_var_allows_normal_names() {
329 assert!(!is_denied_env_var("GITHUB_TOKEN"));
330 assert!(!is_denied_env_var("AWS_SECRET_ACCESS_KEY"));
331 assert!(!is_denied_env_var("CORP_API_KEY"));
332 }
333
334 #[test]
335 fn denied_env_var_sesame_profile() {
336 assert!(is_denied_env_var("SESAME_PROFILE"));
337 }
338
339 #[test]
340 fn json_escape_special_chars() {
341 assert_eq!(json_escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd");
342 }
343
344 #[test]
345 fn json_escape_strips_null() {
346 assert_eq!(json_escape("ab\0cd"), "abcd");
347 }
348
349 #[test]
350 fn json_escape_control_chars() {
351 assert_eq!(json_escape("\x01\x1f"), "\\u0001\\u001f");
352 }
353}