Skip to main content

tsafe_core/
env.rs

1//! Environment variable formatting and injection utilities.
2//!
3//! Provides format functions for rendering vault secrets as shell-evaluable env
4//! assignments (`export KEY="value"`), GitHub Actions workflow commands, and
5//! PowerShell syntax.  Also implements the `exec` env-injection path: building a
6//! child-process environment that strips sensitive tsafe-internal vars before
7//! adding vault secrets.
8
9use std::collections::HashMap;
10use std::path::Path;
11use std::process::Command;
12
13use crate::{
14    errors::{SafeError, SafeResult},
15    profile,
16};
17
18// ── formatting ───────────────────────────────────────────────────────────────
19
20/// KEY=VALUE per line (POSIX env assign syntax).
21pub 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
28/// `export KEY="VALUE"` per line (bash/zsh source-able).
29/// Escapes backslashes, double-quotes, dollar signs, backticks, and newlines.
30pub 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
46/// `$env:KEY = "VALUE"` per line (PowerShell source-able).
47/// Escapes double-quotes, backticks, dollar signs, and newlines for safe evaluation.
48pub 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
63/// JSON object `{ "KEY": "VALUE", … }`.
64pub fn format_json(secrets: &HashMap<String, String>) -> SafeResult<String> {
65    serde_json::to_string_pretty(secrets).map_err(SafeError::Serialization)
66}
67
68/// YAML mapping: one `KEY: "VALUE"` per line, sorted by key.
69///
70/// Values are double-quoted and special characters escaped so the output is
71/// always valid YAML without a dependency on a full YAML serialiser for this
72/// simple key→string mapping shape.
73pub 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                // Escape backslash, double-quote, and newlines for YAML double-quoted scalar.
84                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
96/// Docker `--env-file` format: `KEY=VALUE` per line, sorted by key.
97///
98/// Docker env-file syntax is identical to POSIX `KEY=VALUE`, without `export`
99/// prefixes. Values containing newlines have them escaped as `\n` / `\r` so
100/// each pair stays on one line.
101pub 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
108/// GitHub Actions format: `::add-mask::VALUE` workflow command followed by
109/// `KEY=VALUE` for each secret, sorted by key.
110///
111/// Append to `$GITHUB_ENV` in a `run:` step:
112/// ```yaml
113/// - run: tsafe export --format github-actions >> $GITHUB_ENV
114/// ```
115/// The runner processes `::add-mask::` lines to mask values in log output, and
116/// `KEY=VALUE` lines are loaded into subsequent steps automatically.
117pub 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
127/// TOML flat top-level table: `KEY = "VALUE"` per line, sorted by key.
128///
129/// Bare TOML keys are `[A-Za-z0-9_-]+`; any key that does not satisfy that
130/// pattern is quoted with double-quotes.  Values are TOML basic strings:
131/// backslashes are escaped as `\\` and double-quotes as `\"`.
132pub 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
153/// Returns true when `k` is a valid TOML bare key (`[A-Za-z0-9_-]+`).
154fn 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
160/// Escape a string for use inside TOML double-quoted basic strings.
161/// Escapes: `\` → `\\`, `"` → `\"`.
162fn 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
172// ── .env import ──────────────────────────────────────────────────────────────
173
174/// Parse a `.env` file into a `HashMap`. Handles `#` comments, blank lines,
175/// Parse a `.env`-style file into a key→value map.
176///
177/// Handles the following line forms (and their whitespace-padded variants):
178/// - `KEY=VALUE`                  (POSIX / dotenv)
179/// - `KEY="quoted"` / `KEY='q'`   (quoted values, quotes are stripped)
180/// - `export KEY=VALUE`           (bash/zsh source-able)
181/// - `$KEY = VALUE`               (PowerShell variable style)
182/// - Lines starting with `#`      (comments, skipped)
183/// - Lines without `=`            (free-form text / section headers, silently skipped)
184///
185/// The parser is intentionally lenient: real-world `.env` files often contain
186/// freeform notes or section headers mixed with assignments.
187///
188/// Two-pass parsing is used so that intra-file `$VAR` references resolve correctly
189/// even when the referenced variable is defined earlier in the same file (e.g.
190/// `export ARM_CLIENT_ID="..."` followed by `client_id="$ARM_CLIENT_ID"`).
191/// Resolution order: process environment first, then values from this file.
192pub 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    // Strip UTF-8 BOM if present (common with Windows Notepad-saved files).
198    let content = raw_content.strip_prefix('\u{FEFF}').unwrap_or(&raw_content);
199
200    // Pass 1 — collect raw (un-expanded) key/value pairs.
201    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    // Build a lookup of literal (non-$VAR) values from this file for intra-file
223    // reference resolution in pass 2.
224    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    // Pass 2 — resolve $VAR references: process env first, then intra-file literals.
232    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
250/// Resolve a bare `$VARNAME` reference.
251/// Lookup order: process environment → intra-file literals.
252/// Only exact whole-value references are expanded (e.g. `$ARM_CLIENT_ID`).
253/// If the variable is not found in either source, the literal value is kept.
254fn 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
268// ── process execution ────────────────────────────────────────────────────────
269
270/// Sensitive environment variables that must never be passed to child processes.
271/// The master password and credential vars are stripped before exec.
272const 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    // PAT-style tokens commonly present in CI and developer environments
282    "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
292/// Env var names that can hijack dynamic loaders or language runtimes when injected into a child.
293/// Used by `tsafe exec` to warn or optionally refuse injection ([`is_dangerous_injected_env_name`]).
294const 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
303/// Returns true if this env var name is known to affect loaders or interpreters (ASCII case-insensitive).
304pub 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
310/// Returns the list of parent environment variable names that `tsafe exec` strips before
311/// spawning the child process, including config-driven extras. Used by `--plan` to show which
312/// names would be scrubbed.
313pub 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
330/// Minimal set of parent env vars that most commands need for basic operation.
331///
332/// Used by `tsafe exec --minimal` as a curated safe baseline: no tokens, no credentials,
333/// no loader-hijack vars — just the things that break commands when absent.
334pub const MINIMAL_ENV_VARS: &[&str] = &[
335    // Shell / process identity
336    "PATH",
337    "HOME",
338    "USER",
339    "LOGNAME",
340    "SHELL",
341    // Temp directories
342    "TMPDIR",
343    "TMP",
344    "TEMP",
345    // Locale / encoding
346    "LANG",
347    "LC_ALL",
348    "LC_CTYPE",
349    "LC_MESSAGES",
350    // Terminal (needed by CLI tools that detect color/width)
351    "TERM",
352    "TERM_PROGRAM",
353    "COLORTERM",
354    "NO_COLOR",
355    "FORCE_COLOR",
356    // Working directory hint (set by the shell; not always present)
357    "PWD",
358    // SSH agent socket (needed for git-over-SSH inside the child)
359    "SSH_AUTH_SOCK",
360    "SSH_AGENT_PID",
361    // Linux desktop / XDG (needed by GUI tools and some CLI tools for config dirs)
362    "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    // Strip sensitive vars from the inherited parent environment before injecting secrets.
376    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
393/// Build a command with the inherited parent env (minus sensitive strips) plus vault secrets.
394pub 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
401/// Build a command with the inherited parent env (minus sensitive strips and `extra_strip_names`)
402/// plus vault secrets.
403pub 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
419/// Build a command from a clean environment, adding back only `keep`, then vault secrets.
420pub 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
442/// Spawn `cmd_parts[0]` with `cmd_parts[1..]` as arguments, injecting `secrets`
443/// into its environment (on top of the inherited parent env). Returns exit code.
444pub 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
453/// Like [`exec_with_secrets`] but starts from a clean environment (no parent env inherited),
454/// then adds back only the `keep` entries from the parent, and finally injects `secrets`.
455///
456/// Used by `tsafe exec --no-inherit` and `tsafe exec --only KEY,...`.
457pub 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// ── tests ────────────────────────────────────────────────────────────────────
468
469#[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        // Sorted order: AAA, MY_KEY, ZZZ
496        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        // hyphen is allowed in bare keys, space and dot are not
507        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(); // no = → silently skipped
633        writeln!(f).unwrap(); // blank line
634        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        // Lines without '=' are free-form notes; they must not cause an error.
648        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        // Values like `client_id="$ARM_CLIENT_ID"` are common in .env files that
665        // forward an already-exported variable under a different name.
666        // The parser must resolve $VAR references from:
667        //   a) the process environment, and
668        //   b) other literal values in the same file (intra-file resolution).
669        let mut f = NamedTempFile::new().unwrap();
670        // a) process-environment reference
671        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        // b) intra-file reference: ARM_CLIENT_ID defined above, client_id references it
675        writeln!(f, r#"export ARM_CLIENT_ID="4a71128e-real-uuid""#).unwrap();
676        writeln!(f, r#"client_id="$ARM_CLIENT_ID""#).unwrap();
677        // c) unresolvable reference kept as literal
678        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}