1use anyhow::{Context, Result};
40use indexmap::IndexMap;
41
42pub type EnvMap = IndexMap<String, Option<String>>;
44
45pub fn expand_values(env: &EnvMap) -> Result<Vec<(String, Option<String>)>> {
52 if env.is_empty() {
53 return Ok(Vec::new());
54 }
55 let set_keys: Vec<&String> = env
58 .iter()
59 .filter_map(|(k, v)| v.as_ref().map(|_| k))
60 .collect();
61
62 let expanded: Vec<String> = if set_keys.is_empty() {
63 Vec::new()
64 } else {
65 let mut script = String::new();
66 for (key, value) in env {
67 if let Some(v) = value {
68 script.push_str(&format!("export {}={}\n", key, v));
69 }
70 }
71 for key in &set_keys {
72 script.push_str(&format!("printf '%s\\0' \"${{{}}}\"\n", key));
73 }
74 let output = std::process::Command::new("sh")
75 .arg("-c")
76 .arg(&script)
77 .output()
78 .context("failed to spawn shell for env expansion")?;
79 if !output.status.success() {
80 let stderr = String::from_utf8_lossy(&output.stderr);
81 anyhow::bail!("env expansion failed: {}", stderr.trim());
82 }
83 let stdout =
84 String::from_utf8(output.stdout).context("env expansion output is not valid UTF-8")?;
85 stdout
86 .split('\0')
87 .take(set_keys.len())
88 .map(|s| s.to_string())
89 .collect()
90 };
91
92 let mut set_iter = expanded.into_iter();
93 let mut result = Vec::with_capacity(env.len());
94 for (key, value) in env {
95 match value {
96 Some(_) => {
97 let v = set_iter.next().unwrap_or_default();
98 result.push((key.clone(), Some(v)));
99 }
100 None => {
101 result.push((key.clone(), None));
102 }
103 }
104 }
105 Ok(result)
106}
107
108pub fn shell_export_prefix(env: &EnvMap) -> String {
113 if env.is_empty() {
114 return String::new();
115 }
116 let mut prefix = String::new();
117 for (key, value) in env {
118 match value {
119 Some(v) => {
120 let escaped = v.replace('"', "\\\"");
121 prefix.push_str(&format!("export {}=\"{}\" && ", key, escaped));
122 }
123 None => {
124 prefix.push_str(&format!("unset {} && ", key));
125 }
126 }
127 }
128 prefix
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn set(k: &str, v: &str) -> (String, Option<String>) {
136 (k.to_string(), Some(v.to_string()))
137 }
138 fn unset(k: &str) -> (String, Option<String>) {
139 (k.to_string(), None)
140 }
141
142 #[test]
143 fn expand_values_plain() {
144 let env: EnvMap = [set("FOO", "bar"), set("BAZ", "qux")].into_iter().collect();
145 let result = expand_values(&env).unwrap();
146 assert_eq!(
147 result,
148 vec![
149 ("FOO".to_string(), Some("bar".to_string())),
150 ("BAZ".to_string(), Some("qux".to_string())),
151 ]
152 );
153 }
154
155 #[test]
156 fn expand_values_shell_expansion() {
157 let env: EnvMap = [set("GREETING", "$(echo hello)")].into_iter().collect();
158 let result = expand_values(&env).unwrap();
159 assert_eq!(result[0].1, Some("hello".to_string()));
160 }
161
162 #[test]
163 fn expand_values_cross_reference() {
164 let env: EnvMap = [set("BASE", "world"), set("DERIVED", "$BASE")]
165 .into_iter()
166 .collect();
167 let result = expand_values(&env).unwrap();
168 assert_eq!(result[0], ("BASE".to_string(), Some("world".to_string())));
169 assert_eq!(
170 result[1],
171 ("DERIVED".to_string(), Some("world".to_string()))
172 );
173 }
174
175 #[test]
176 fn expand_values_unset_preserved() {
177 let env: EnvMap = [unset("FOO")].into_iter().collect();
178 let result = expand_values(&env).unwrap();
179 assert_eq!(result, vec![("FOO".to_string(), None)]);
180 }
181
182 #[test]
183 fn expand_values_mixed_set_and_unset_document_order() {
184 let env: EnvMap = [
185 set("FIRST", "1"),
186 unset("MIDDLE"),
187 set("LAST", "$FIRST-tail"),
188 ]
189 .into_iter()
190 .collect();
191 let result = expand_values(&env).unwrap();
192 assert_eq!(result[0], ("FIRST".to_string(), Some("1".to_string())));
193 assert_eq!(result[1], ("MIDDLE".to_string(), None));
194 assert_eq!(result[2], ("LAST".to_string(), Some("1-tail".to_string())));
195 }
196
197 #[test]
198 fn expand_values_empty() {
199 let env: EnvMap = IndexMap::new();
200 let result = expand_values(&env).unwrap();
201 assert!(result.is_empty());
202 }
203
204 #[test]
205 fn shell_export_prefix_empty() {
206 let env: EnvMap = IndexMap::new();
207 assert_eq!(shell_export_prefix(&env), "");
208 }
209
210 #[test]
211 fn shell_export_prefix_basic() {
212 let env: EnvMap = [set("K1", "v1"), set("K2", "v2")].into_iter().collect();
213 assert_eq!(
214 shell_export_prefix(&env),
215 "export K1=\"v1\" && export K2=\"v2\" && "
216 );
217 }
218
219 #[test]
220 fn shell_export_prefix_unset() {
221 let env: EnvMap = [unset("K1")].into_iter().collect();
222 assert_eq!(shell_export_prefix(&env), "unset K1 && ");
223 }
224
225 #[test]
226 fn shell_export_prefix_mixed() {
227 let env: EnvMap = [set("K1", "v1"), unset("K2"), set("K3", "v3")]
228 .into_iter()
229 .collect();
230 assert_eq!(
231 shell_export_prefix(&env),
232 "export K1=\"v1\" && unset K2 && export K3=\"v3\" && "
233 );
234 }
235
236 #[test]
237 fn shell_export_prefix_quotes_in_value() {
238 let env: EnvMap = [set("K", "a\"b")].into_iter().collect();
239 assert_eq!(shell_export_prefix(&env), "export K=\"a\\\"b\" && ");
240 }
241
242 #[test]
243 fn shell_export_prefix_unexpanded_passage() {
244 let env: EnvMap = [set("TOKEN", "$(passage btak/secret)")]
245 .into_iter()
246 .collect();
247 let prefix = shell_export_prefix(&env);
248 assert!(prefix.contains("$(passage btak/secret)"));
249 assert!(!prefix.contains("export TOKEN=\"\""));
250 }
251}