1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct SecretBinding {
17 pub env_var: String,
19 pub target_host: String,
21 #[serde(default = "default_header")]
23 pub header: String,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub value: Option<String>,
27}
28
29fn default_header() -> String {
30 "Authorization".to_string()
31}
32
33pub const PLACEHOLDER_PREFIX: &str = "mvm-managed:";
35
36impl SecretBinding {
37 pub fn new(env_var: impl Into<String>, target_host: impl Into<String>) -> Self {
38 Self {
39 env_var: env_var.into(),
40 target_host: target_host.into(),
41 header: default_header(),
42 value: None,
43 }
44 }
45
46 pub fn with_header(mut self, header: impl Into<String>) -> Self {
47 self.header = header.into();
48 self
49 }
50
51 pub fn with_value(mut self, value: impl Into<String>) -> Self {
52 self.value = Some(value.into());
53 self
54 }
55
56 pub fn resolve_value(&self) -> anyhow::Result<String> {
59 if let Some(ref v) = self.value {
60 Ok(v.clone())
61 } else {
62 std::env::var(&self.env_var).map_err(|_| {
63 anyhow::anyhow!(
64 "secret {:?} not set in host environment and no explicit value provided",
65 self.env_var
66 )
67 })
68 }
69 }
70
71 pub fn placeholder(&self) -> String {
73 format!("{}{}", PLACEHOLDER_PREFIX, self.env_var)
74 }
75
76 pub fn secret_filename(&self) -> String {
79 self.env_var.to_lowercase().replace('.', "_")
80 }
81}
82
83impl fmt::Display for SecretBinding {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 write!(f, "{}:{}", self.env_var, self.target_host)?;
86 if self.header != "Authorization" {
87 write!(f, ":{}", self.header)?;
88 }
89 Ok(())
90 }
91}
92
93impl FromStr for SecretBinding {
99 type Err = anyhow::Error;
100
101 fn from_str(s: &str) -> Result<Self, Self::Err> {
102 let (key_part, rest) = s
104 .split_once(':')
105 .ok_or_else(|| anyhow::anyhow!("expected KEY:host or KEY=value:host, got {:?}", s))?;
106
107 let (env_var, value) = if let Some((k, v)) = key_part.split_once('=') {
109 (k.to_string(), Some(v.to_string()))
110 } else {
111 (key_part.to_string(), None)
112 };
113
114 if env_var.is_empty() {
115 anyhow::bail!("empty environment variable name in {:?}", s);
116 }
117
118 let (target_host, header) = if let Some((h, hdr)) = rest.split_once(':') {
120 (h.to_string(), hdr.to_string())
121 } else {
122 (rest.to_string(), default_header())
123 };
124
125 if target_host.is_empty() {
126 anyhow::bail!("empty target host in {:?}", s);
127 }
128
129 Ok(Self {
130 env_var,
131 target_host,
132 header,
133 value,
134 })
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ResolvedSecrets {
142 pub bindings: Vec<ResolvedBinding>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ResolvedBinding {
148 pub env_var: String,
149 pub target_host: String,
150 pub header: String,
151 pub value: String,
152}
153
154impl ResolvedSecrets {
155 pub fn resolve(bindings: &[SecretBinding]) -> anyhow::Result<Self> {
157 let resolved = bindings
158 .iter()
159 .map(|b| {
160 let value = b.resolve_value()?;
161 Ok(ResolvedBinding {
162 env_var: b.env_var.clone(),
163 target_host: b.target_host.clone(),
164 header: b.header.clone(),
165 value,
166 })
167 })
168 .collect::<anyhow::Result<Vec<_>>>()?;
169 Ok(Self { bindings: resolved })
170 }
171
172 pub fn to_secret_files(&self) -> Vec<(String, String)> {
175 self.bindings
176 .iter()
177 .map(|b| {
178 let filename = b.env_var.to_lowercase().replace('.', "_");
179 let content = serde_json::json!({
180 "env_var": b.env_var,
181 "target_host": b.target_host,
182 "header": b.header,
183 "value": b.value,
184 });
185 (filename, content.to_string())
186 })
187 .collect()
188 }
189
190 pub fn placeholder_env_vars(&self) -> Vec<(String, String)> {
193 self.bindings
194 .iter()
195 .map(|b| {
196 (
197 b.env_var.clone(),
198 format!("{}{}", PLACEHOLDER_PREFIX, b.env_var),
199 )
200 })
201 .collect()
202 }
203
204 pub fn manifest_json(&self) -> String {
207 let entries: Vec<serde_json::Value> = self
208 .bindings
209 .iter()
210 .map(|b| {
211 serde_json::json!({
212 "env_var": b.env_var,
213 "target_host": b.target_host,
214 "header": b.header,
215 "secret_file": b.env_var.to_lowercase().replace('.', "_"),
216 })
217 })
218 .collect();
219 serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn parse_simple_binding() {
229 let b: SecretBinding = "OPENAI_API_KEY:api.openai.com".parse().unwrap();
230 assert_eq!(b.env_var, "OPENAI_API_KEY");
231 assert_eq!(b.target_host, "api.openai.com");
232 assert_eq!(b.header, "Authorization");
233 assert!(b.value.is_none());
234 }
235
236 #[test]
237 fn parse_with_header() {
238 let b: SecretBinding = "ANTHROPIC_KEY:api.anthropic.com:x-api-key".parse().unwrap();
239 assert_eq!(b.env_var, "ANTHROPIC_KEY");
240 assert_eq!(b.target_host, "api.anthropic.com");
241 assert_eq!(b.header, "x-api-key");
242 }
243
244 #[test]
245 fn parse_with_value() {
246 let b: SecretBinding = "MY_KEY=sk-123:api.example.com".parse().unwrap();
247 assert_eq!(b.env_var, "MY_KEY");
248 assert_eq!(b.value, Some("sk-123".to_string()));
249 assert_eq!(b.target_host, "api.example.com");
250 }
251
252 #[test]
253 fn parse_with_value_and_header() {
254 let b: SecretBinding = "KEY=val:host.com:x-token".parse().unwrap();
255 assert_eq!(b.env_var, "KEY");
256 assert_eq!(b.value, Some("val".to_string()));
257 assert_eq!(b.target_host, "host.com");
258 assert_eq!(b.header, "x-token");
259 }
260
261 #[test]
262 fn parse_missing_host() {
263 assert!("KEY".parse::<SecretBinding>().is_err());
264 }
265
266 #[test]
267 fn parse_empty_key() {
268 assert!(":host.com".parse::<SecretBinding>().is_err());
269 }
270
271 #[test]
272 fn parse_empty_host() {
273 assert!("KEY:".parse::<SecretBinding>().is_err());
274 }
275
276 #[test]
277 fn display_simple() {
278 let b = SecretBinding::new("KEY", "host.com");
279 assert_eq!(b.to_string(), "KEY:host.com");
280 }
281
282 #[test]
283 fn display_with_header() {
284 let b = SecretBinding::new("KEY", "host.com").with_header("x-token");
285 assert_eq!(b.to_string(), "KEY:host.com:x-token");
286 }
287
288 #[test]
289 fn placeholder() {
290 let b = SecretBinding::new("OPENAI_API_KEY", "api.openai.com");
291 assert_eq!(b.placeholder(), "mvm-managed:OPENAI_API_KEY");
292 }
293
294 #[test]
295 fn serde_roundtrip() {
296 let b = SecretBinding::new("KEY", "host.com")
297 .with_header("x-token")
298 .with_value("secret");
299 let json = serde_json::to_string(&b).unwrap();
300 let parsed: SecretBinding = serde_json::from_str(&json).unwrap();
301 assert_eq!(parsed, b);
302 }
303
304 #[test]
305 fn serde_without_value_omits_field() {
306 let b = SecretBinding::new("KEY", "host.com");
307 let json = serde_json::to_string(&b).unwrap();
308 assert!(!json.contains("value"));
309 }
310
311 #[test]
312 fn resolve_value_explicit() {
313 let b = SecretBinding::new("NONEXISTENT_VAR", "host.com").with_value("explicit");
314 assert_eq!(b.resolve_value().unwrap(), "explicit");
315 }
316
317 #[test]
318 fn resolve_value_from_env() {
319 unsafe { std::env::set_var("MVM_TEST_SECRET_42", "from-env") };
320 let b = SecretBinding::new("MVM_TEST_SECRET_42", "host.com");
321 assert_eq!(b.resolve_value().unwrap(), "from-env");
322 unsafe { std::env::remove_var("MVM_TEST_SECRET_42") };
323 }
324
325 #[test]
326 fn resolve_value_missing_env() {
327 let b = SecretBinding::new("DEFINITELY_NOT_SET_XYZ", "host.com");
328 assert!(b.resolve_value().is_err());
329 }
330
331 #[test]
332 fn resolved_secrets_files() {
333 let resolved = ResolvedSecrets {
334 bindings: vec![ResolvedBinding {
335 env_var: "OPENAI_API_KEY".to_string(),
336 target_host: "api.openai.com".to_string(),
337 header: "Authorization".to_string(),
338 value: "sk-test".to_string(),
339 }],
340 };
341 let files = resolved.to_secret_files();
342 assert_eq!(files.len(), 1);
343 assert_eq!(files[0].0, "openai_api_key");
344 assert!(files[0].1.contains("sk-test"));
345 }
346
347 #[test]
348 fn resolved_secrets_placeholders() {
349 let resolved = ResolvedSecrets {
350 bindings: vec![
351 ResolvedBinding {
352 env_var: "KEY_A".to_string(),
353 target_host: "a.com".to_string(),
354 header: "Authorization".to_string(),
355 value: "val-a".to_string(),
356 },
357 ResolvedBinding {
358 env_var: "KEY_B".to_string(),
359 target_host: "b.com".to_string(),
360 header: "x-token".to_string(),
361 value: "val-b".to_string(),
362 },
363 ],
364 };
365 let placeholders = resolved.placeholder_env_vars();
366 assert_eq!(placeholders.len(), 2);
367 assert_eq!(placeholders[0].0, "KEY_A");
368 assert_eq!(placeholders[0].1, "mvm-managed:KEY_A");
369 }
370
371 #[test]
372 fn resolved_secrets_manifest() {
373 let resolved = ResolvedSecrets {
374 bindings: vec![ResolvedBinding {
375 env_var: "KEY".to_string(),
376 target_host: "host.com".to_string(),
377 header: "x-token".to_string(),
378 value: "secret".to_string(),
379 }],
380 };
381 let manifest = resolved.manifest_json();
382 let parsed: Vec<serde_json::Value> = serde_json::from_str(&manifest).unwrap();
383 assert_eq!(parsed.len(), 1);
384 assert_eq!(parsed[0]["env_var"], "KEY");
385 assert_eq!(parsed[0]["target_host"], "host.com");
386 assert!(parsed[0].get("value").is_none());
388 }
389}