1use std::collections::HashMap;
10use std::path::Path;
11use std::process::Command;
12
13use crate::{
14 errors::{SafeError, SafeResult},
15 profile,
16};
17
18pub fn format_env(secrets: &HashMap<String, String>) -> String {
22 sorted_pairs(secrets)
23 .map(|(k, v)| format!("{k}={}", v.replace('\n', "\\n").replace('\r', "\\r")))
24 .collect::<Vec<_>>()
25 .join("\n")
26}
27
28pub fn format_dotenv(secrets: &HashMap<String, String>) -> String {
31 sorted_pairs(secrets)
32 .map(|(k, v)| {
33 let escaped = v
34 .replace('\\', "\\\\")
35 .replace('"', "\\\"")
36 .replace('$', "\\$")
37 .replace('`', "\\`")
38 .replace('\n', "\\n")
39 .replace('\r', "\\r");
40 format!("export {k}=\"{escaped}\"")
41 })
42 .collect::<Vec<_>>()
43 .join("\n")
44}
45
46pub fn format_powershell(secrets: &HashMap<String, String>) -> String {
49 sorted_pairs(secrets)
50 .map(|(k, v)| {
51 let escaped = v
52 .replace('`', "``")
53 .replace('"', "`\"")
54 .replace('$', "`$")
55 .replace('\n', "`n")
56 .replace('\r', "`r");
57 format!("$env:{k} = \"{escaped}\"")
58 })
59 .collect::<Vec<_>>()
60 .join("\n")
61}
62
63pub fn format_json(secrets: &HashMap<String, String>) -> SafeResult<String> {
65 serde_json::to_string_pretty(secrets).map_err(SafeError::Serialization)
66}
67
68pub fn format_yaml(secrets: &HashMap<String, String>) -> SafeResult<String> {
74 let lines: Vec<String> = {
75 let mut pairs: Vec<(&str, &str)> = secrets
76 .iter()
77 .map(|(k, v)| (k.as_str(), v.as_str()))
78 .collect();
79 pairs.sort_by_key(|(k, _)| *k);
80 pairs
81 .into_iter()
82 .map(|(k, v)| {
83 let escaped = v
85 .replace('\\', "\\\\")
86 .replace('"', "\\\"")
87 .replace('\n', "\\n")
88 .replace('\r', "\\r");
89 format!("{k}: \"{escaped}\"")
90 })
91 .collect()
92 };
93 Ok(lines.join("\n"))
94}
95
96pub fn format_docker_env(secrets: &HashMap<String, String>) -> String {
102 sorted_pairs(secrets)
103 .map(|(k, v)| format!("{k}={}", v.replace('\n', "\\n").replace('\r', "\\r")))
104 .collect::<Vec<_>>()
105 .join("\n")
106}
107
108pub fn format_github_actions(secrets: &HashMap<String, String>) -> String {
118 sorted_pairs(secrets)
119 .flat_map(|(k, v)| {
120 let safe_v = v.replace('\n', "%0A").replace('\r', "%0D");
121 [format!("::add-mask::{safe_v}"), format!("{k}={safe_v}")]
122 })
123 .collect::<Vec<_>>()
124 .join("\n")
125}
126
127pub fn format_toml(pairs: &[(impl AsRef<str>, impl AsRef<str>)]) -> String {
133 let mut sorted: Vec<(&str, &str)> = pairs
134 .iter()
135 .map(|(k, v)| (k.as_ref(), v.as_ref()))
136 .collect();
137 sorted.sort_by_key(|(k, _)| *k);
138 sorted
139 .into_iter()
140 .map(|(k, v)| {
141 let key = if is_bare_toml_key(k) {
142 k.to_owned()
143 } else {
144 format!("\"{}\"", escape_toml_string(k))
145 };
146 let value = escape_toml_string(v);
147 format!("{key} = \"{value}\"")
148 })
149 .collect::<Vec<_>>()
150 .join("\n")
151}
152
153fn is_bare_toml_key(k: &str) -> bool {
155 !k.is_empty()
156 && k.chars()
157 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
158}
159
160fn escape_toml_string(s: &str) -> String {
163 s.replace('\\', "\\\\").replace('"', "\\\"")
164}
165
166fn sorted_pairs(m: &HashMap<String, String>) -> impl Iterator<Item = (&str, &str)> {
167 let mut pairs: Vec<(&str, &str)> = m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
168 pairs.sort_by_key(|(k, _)| *k);
169 pairs.into_iter()
170}
171
172pub fn parse_dotenv(path: &Path) -> SafeResult<HashMap<String, String>> {
193 let raw_content = std::fs::read_to_string(path).map_err(|e| SafeError::ImportParse {
194 file: path.display().to_string(),
195 reason: e.to_string(),
196 })?;
197 let content = raw_content.strip_prefix('\u{FEFF}').unwrap_or(&raw_content);
199
200 let mut raw_pairs: Vec<(String, String)> = Vec::new();
202 for raw in content.lines() {
203 let line = raw.trim();
204 if line.is_empty() || line.starts_with('#') {
205 continue;
206 }
207 let Some(eq) = line.find('=') else { continue };
208 let raw_key = line[..eq].trim();
209 let raw_key = raw_key
210 .strip_prefix("export")
211 .map(str::trim)
212 .unwrap_or(raw_key);
213 let raw_key = raw_key.strip_prefix('$').unwrap_or(raw_key);
214 let key = raw_key.trim().to_string();
215 if key.is_empty() || key.contains(|c: char| c.is_whitespace()) {
216 continue;
217 }
218 let raw_val = strip_quotes(line[eq + 1..].trim()).to_string();
219 raw_pairs.push((key, raw_val));
220 }
221
222 let mut literals: HashMap<String, String> = HashMap::new();
225 for (k, v) in &raw_pairs {
226 if !v.starts_with('$') {
227 literals.insert(k.clone(), v.clone());
228 }
229 }
230
231 let mut map = HashMap::new();
233 for (key, val) in raw_pairs {
234 let resolved = resolve_env_ref(&val, &literals);
235 map.insert(key, resolved);
236 }
237 Ok(map)
238}
239
240fn strip_quotes(s: &str) -> &str {
241 if s.len() >= 2
242 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
243 {
244 &s[1..s.len() - 1]
245 } else {
246 s
247 }
248}
249
250fn resolve_env_ref(val: &str, file_locals: &HashMap<String, String>) -> String {
255 if let Some(name) = val.strip_prefix('$') {
256 if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
257 if let Ok(resolved) = std::env::var(name) {
258 return resolved;
259 }
260 if let Some(resolved) = file_locals.get(name) {
261 return resolved.clone();
262 }
263 }
264 }
265 val.to_string()
266}
267
268const SENSITIVE_VARS: &[&str] = &[
273 "TSAFE_PASSWORD",
274 "TSAFE_NEW_MASTER_PASSWORD",
275 "AZURE_CLIENT_SECRET",
276 "VAULT_TOKEN",
277 "TSAFE_AKV_URL",
278 "TSAFE_HCP_URL",
279 "AWS_SECRET_ACCESS_KEY",
280 "AWS_SESSION_TOKEN",
281 "ADO_PAT",
283 "ADO_PAT2",
284 "GITHUB_TOKEN",
285 "GH_TOKEN",
286 "GITLAB_TOKEN",
287 "NPM_TOKEN",
288 "PYPI_TOKEN",
289 "NUGET_API_KEY",
290];
291
292const DANGEROUS_INJECTED_ENV_NAMES: &[&str] = &[
295 "LD_PRELOAD",
296 "LD_LIBRARY_PATH",
297 "NODE_OPTIONS",
298 "DYLD_INSERT_LIBRARIES",
299 "DYLD_LIBRARY_PATH",
300 "DYLD_FRAMEWORK_PATH",
301];
302
303pub fn is_dangerous_injected_env_name(name: &str) -> bool {
305 DANGEROUS_INJECTED_ENV_NAMES
306 .iter()
307 .any(|d| d.eq_ignore_ascii_case(name))
308}
309
310pub fn sensitive_parent_env_vars() -> Vec<String> {
314 let mut out = Vec::new();
315 for name in SENSITIVE_VARS
316 .iter()
317 .map(|name| (*name).to_string())
318 .chain(profile::get_exec_extra_sensitive_parent_vars())
319 {
320 if !out
321 .iter()
322 .any(|existing: &String| existing.eq_ignore_ascii_case(&name))
323 {
324 out.push(name);
325 }
326 }
327 out
328}
329
330pub const MINIMAL_ENV_VARS: &[&str] = &[
335 "PATH",
337 "HOME",
338 "USER",
339 "LOGNAME",
340 "SHELL",
341 "TMPDIR",
343 "TMP",
344 "TEMP",
345 "LANG",
347 "LC_ALL",
348 "LC_CTYPE",
349 "LC_MESSAGES",
350 "TERM",
352 "TERM_PROGRAM",
353 "COLORTERM",
354 "NO_COLOR",
355 "FORCE_COLOR",
356 "PWD",
358 "SSH_AUTH_SOCK",
360 "SSH_AGENT_PID",
361 "DISPLAY",
363 "WAYLAND_DISPLAY",
364 "XDG_RUNTIME_DIR",
365 "XDG_CONFIG_HOME",
366 "XDG_DATA_HOME",
367 "XDG_CACHE_HOME",
368];
369
370fn apply_exec_environment(
371 cmd: &mut Command,
372 secrets: &HashMap<String, String>,
373 extra_strip_names: &[String],
374) {
375 let mut strip_names = sensitive_parent_env_vars();
377 for name in extra_strip_names {
378 if !strip_names
379 .iter()
380 .any(|existing| existing.eq_ignore_ascii_case(name))
381 {
382 strip_names.push(name.clone());
383 }
384 }
385 for var in strip_names {
386 cmd.env_remove(var);
387 }
388 for (k, v) in secrets {
389 cmd.env(k, v);
390 }
391}
392
393pub fn command_with_secrets(
395 secrets: &HashMap<String, String>,
396 cmd_parts: &[String],
397) -> SafeResult<Command> {
398 command_with_secrets_and_extra_strips(secrets, &[], cmd_parts)
399}
400
401pub fn command_with_secrets_and_extra_strips(
404 secrets: &HashMap<String, String>,
405 extra_strip_names: &[String],
406 cmd_parts: &[String],
407) -> SafeResult<Command> {
408 if cmd_parts.is_empty() {
409 return Err(SafeError::InvalidVault {
410 reason: "no command provided for exec".into(),
411 });
412 }
413 let mut cmd = Command::new(&cmd_parts[0]);
414 cmd.args(&cmd_parts[1..]);
415 apply_exec_environment(&mut cmd, secrets, extra_strip_names);
416 Ok(cmd)
417}
418
419pub fn clean_env_command(
421 secrets: &HashMap<String, String>,
422 keep: &HashMap<String, String>,
423 cmd_parts: &[String],
424) -> SafeResult<Command> {
425 if cmd_parts.is_empty() {
426 return Err(SafeError::InvalidVault {
427 reason: "no command provided for exec".into(),
428 });
429 }
430 let mut cmd = Command::new(&cmd_parts[0]);
431 cmd.args(&cmd_parts[1..]);
432 cmd.env_clear();
433 for (k, v) in keep {
434 cmd.env(k, v);
435 }
436 for (k, v) in secrets {
437 cmd.env(k, v);
438 }
439 Ok(cmd)
440}
441
442pub fn exec_with_secrets(
445 secrets: &HashMap<String, String>,
446 cmd_parts: &[String],
447) -> SafeResult<i32> {
448 let mut cmd = command_with_secrets(secrets, cmd_parts)?;
449 let status = cmd.status()?;
450 Ok(status.code().unwrap_or(1))
451}
452
453pub fn exec_clean_env(
458 secrets: &HashMap<String, String>,
459 keep: &HashMap<String, String>,
460 cmd_parts: &[String],
461) -> SafeResult<i32> {
462 let mut cmd = clean_env_command(secrets, keep, cmd_parts)?;
463 let status = cmd.status()?;
464 Ok(status.code().unwrap_or(1))
465}
466
467#[cfg(test)]
470mod tests {
471 use super::*;
472 use std::io::Write;
473 use tempfile::NamedTempFile;
474
475 fn sample() -> HashMap<String, String> {
476 let mut m = HashMap::new();
477 m.insert("ZZZ".into(), "z".into());
478 m.insert("AAA".into(), "a".into());
479 m.insert("MMM".into(), "m with \"quotes\"".into());
480 m
481 }
482
483 #[test]
484 fn format_toml_bare_keys_and_basic_string_values() {
485 let pairs = vec![
486 ("ZZZ".to_string(), "z".to_string()),
487 ("AAA".to_string(), "a".to_string()),
488 (
489 "MY_KEY".to_string(),
490 "val with \"quotes\" and \\backslash".to_string(),
491 ),
492 ];
493 let out = format_toml(&pairs);
494 let lines: Vec<&str> = out.lines().collect();
495 assert_eq!(lines[0], "AAA = \"a\"");
497 assert_eq!(
498 lines[1],
499 r#"MY_KEY = "val with \"quotes\" and \\backslash""#
500 );
501 assert_eq!(lines[2], "ZZZ = \"z\"");
502 }
503
504 #[test]
505 fn format_toml_non_bare_key_is_quoted() {
506 let pairs = vec![
508 ("my-key".to_string(), "v1".to_string()),
509 ("my key".to_string(), "v2".to_string()),
510 ("my.key".to_string(), "v3".to_string()),
511 ];
512 let out = format_toml(&pairs);
513 assert!(out.contains("my-key = \"v1\""), "hyphen key must be bare");
514 assert!(
515 out.contains("\"my key\" = \"v2\""),
516 "space key must be quoted"
517 );
518 assert!(
519 out.contains("\"my.key\" = \"v3\""),
520 "dot key must be quoted"
521 );
522 }
523
524 #[test]
525 fn format_toml_empty_input_produces_empty_string() {
526 let pairs: Vec<(String, String)> = vec![];
527 assert_eq!(format_toml(&pairs), "");
528 }
529
530 #[test]
531 fn format_env_sorted_output() {
532 let out = format_env(&sample());
533 let lines: Vec<&str> = out.lines().collect();
534 assert_eq!(lines[0], "AAA=a");
535 assert!(lines[2].starts_with("ZZZ="));
536 }
537
538 #[test]
539 fn format_json_valid() {
540 let json = format_json(&sample()).unwrap();
541 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
542 assert_eq!(parsed["AAA"], "a");
543 }
544
545 #[test]
546 fn format_env_escapes_newlines_and_carriage_returns() {
547 let mut secrets = HashMap::new();
548 secrets.insert("MULTI".into(), "line1\nline2\rline3".into());
549 assert_eq!(format_env(&secrets), "MULTI=line1\\nline2\\rline3");
550 }
551
552 #[test]
553 fn format_github_actions_escapes_newlines_and_carriage_returns() {
554 let mut secrets = HashMap::new();
555 secrets.insert("MULTI".into(), "line1\nline2\rline3".into());
556 let output = format_github_actions(&secrets);
557 let lines: Vec<&str> = output.lines().collect();
558 assert_eq!(lines[0], "::add-mask::line1%0Aline2%0Dline3");
559 assert_eq!(lines[1], "MULTI=line1%0Aline2%0Dline3");
560 }
561
562 #[test]
563 fn dangerous_injected_env_names_detected_case_insensitive() {
564 assert!(is_dangerous_injected_env_name("NODE_OPTIONS"));
565 assert!(is_dangerous_injected_env_name("node_options"));
566 assert!(!is_dangerous_injected_env_name("API_KEY"));
567 }
568
569 #[test]
570 fn apply_exec_environment_removes_sensitive_vars_and_injects_secrets() {
571 let mut secrets = HashMap::new();
572 secrets.insert("APP_TOKEN".into(), "value-123".into());
573 let mut cmd = Command::new("placeholder");
574 apply_exec_environment(&mut cmd, &secrets, &[]);
575
576 let envs: HashMap<String, Option<String>> = cmd
577 .get_envs()
578 .map(|(key, value)| {
579 (
580 key.to_string_lossy().into_owned(),
581 value.map(|item| item.to_string_lossy().into_owned()),
582 )
583 })
584 .collect();
585
586 assert_eq!(envs.get("APP_TOKEN"), Some(&Some("value-123".into())));
587 for var in SENSITIVE_VARS {
588 assert_eq!(
589 envs.get(*var),
590 Some(&None),
591 "expected '{var}' to be removed from child environment"
592 );
593 }
594 }
595
596 #[test]
597 fn apply_exec_environment_removes_extra_strip_vars_even_when_not_globally_sensitive() {
598 let mut secrets = HashMap::new();
599 secrets.insert("GH_TOKEN".into(), "vault-gh-token".into());
600 let mut cmd = Command::new("placeholder");
601 apply_exec_environment(
602 &mut cmd,
603 &secrets,
604 &["DOCKER_PASSWORD".to_string(), "TWINE_PASSWORD".to_string()],
605 );
606
607 let envs: HashMap<String, Option<String>> = cmd
608 .get_envs()
609 .map(|(key, value)| {
610 (
611 key.to_string_lossy().into_owned(),
612 value.map(|item| item.to_string_lossy().into_owned()),
613 )
614 })
615 .collect();
616
617 assert_eq!(envs.get("GH_TOKEN"), Some(&Some("vault-gh-token".into())));
618 assert_eq!(envs.get("DOCKER_PASSWORD"), Some(&None));
619 assert_eq!(envs.get("TWINE_PASSWORD"), Some(&None));
620 }
621
622 #[test]
623 fn parse_dotenv_all_forms() {
624 let mut f = NamedTempFile::new().unwrap();
625 writeln!(f, "# comment").unwrap();
626 writeln!(f, "K1=plain").unwrap();
627 writeln!(f, "K2=\"double\"").unwrap();
628 writeln!(f, "K3='single'").unwrap();
629 writeln!(f, "K4 = spaced ").unwrap();
630 writeln!(f, "export K5=bash").unwrap();
631 writeln!(f, "$K6 = powershell").unwrap();
632 writeln!(f, "SECTION_HEADER").unwrap(); writeln!(f).unwrap(); let m = parse_dotenv(f.path()).unwrap();
635 assert_eq!(m["K1"], "plain");
636 assert_eq!(m["K2"], "double");
637 assert_eq!(m["K3"], "single");
638 assert_eq!(m["K4"], "spaced");
639 assert_eq!(m["K5"], "bash");
640 assert_eq!(m["K6"], "powershell");
641 assert!(!m.contains_key("#"));
642 assert!(!m.contains_key("SECTION_HEADER"));
643 }
644
645 #[test]
646 fn parse_dotenv_no_eq_is_skipped() {
647 let mut f = NamedTempFile::new().unwrap();
649 writeln!(f, "NOEQUALS").unwrap();
650 writeln!(f, "KEY=value").unwrap();
651 let m = parse_dotenv(f.path()).unwrap();
652 assert_eq!(m["KEY"], "value");
653 assert!(!m.contains_key("NOEQUALS"));
654 }
655
656 #[test]
657 fn parse_dotenv_file_not_found() {
658 let result = parse_dotenv(Path::new("/tmp/tsafe-nonexistent-9999.env"));
659 assert!(matches!(result, Err(SafeError::ImportParse { .. })));
660 }
661
662 #[test]
663 fn parse_dotenv_env_var_reference_is_resolved() {
664 let mut f = NamedTempFile::new().unwrap();
670 writeln!(f, r#"K_BARE=$TSAFE_TEST_RESOLVE_VAR"#).unwrap();
672 writeln!(f, r#"K_DOUBLE="$TSAFE_TEST_RESOLVE_VAR""#).unwrap();
673 writeln!(f, r#"K_SINGLE='$TSAFE_TEST_RESOLVE_VAR'"#).unwrap();
674 writeln!(f, r#"export ARM_CLIENT_ID="4a71128e-real-uuid""#).unwrap();
676 writeln!(f, r#"client_id="$ARM_CLIENT_ID""#).unwrap();
677 writeln!(f, r#"K_UNSET=$TSAFE_TEST_NO_SUCH_VAR_XYZ"#).unwrap();
679 let m = temp_env::with_var("TSAFE_TEST_RESOLVE_VAR", Some("resolved-value-abc"), || {
680 parse_dotenv(f.path()).unwrap()
681 });
682 assert_eq!(
683 m["K_BARE"], "resolved-value-abc",
684 "bare $VAR from env should resolve"
685 );
686 assert_eq!(
687 m["K_DOUBLE"], "resolved-value-abc",
688 "double-quoted $VAR from env should resolve"
689 );
690 assert_eq!(
691 m["K_SINGLE"], "resolved-value-abc",
692 "single-quoted $VAR from env should resolve"
693 );
694 assert_eq!(
695 m["ARM_CLIENT_ID"], "4a71128e-real-uuid",
696 "literal should be stored as-is"
697 );
698 assert_eq!(
699 m["client_id"], "4a71128e-real-uuid",
700 "$VAR intra-file reference should resolve"
701 );
702 assert_eq!(
703 m["K_UNSET"], "$TSAFE_TEST_NO_SUCH_VAR_XYZ",
704 "unset $VAR kept as literal"
705 );
706 }
707}