1use std::env;
2use std::fs::{
3 self,
4 File,
5};
6use std::io::Write as _;
7use std::path::{
8 Path,
9 PathBuf,
10};
11use std::process::{
12 Command,
13 Stdio,
14};
15
16use anyhow::Context as _;
17use hashbrown::HashMap;
18use pgp::composed::{
19 Deserializable as _,
20 Message,
21 SignedSecretKey,
22};
23use schemars::JsonSchema;
24use serde::{
25 Deserialize,
26 Serialize,
27};
28
29use crate::file::ToUtf8 as _;
30use crate::utils::{
31 parse_env_contents,
32 resolve_path,
33};
34
35const VAULT_META_FILE: &str = ".vault-meta.toml";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SecretValueSource {
39 Cli,
40 Task,
41 Root,
42 VaultMeta,
43 Default,
44}
45
46#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
47#[serde(rename_all = "snake_case")]
48pub enum SecretBackend {
49 #[default]
50 BuiltInPgp,
51 Gpg,
52}
53
54#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
55pub struct SecretSettings {
56 #[serde(default)]
57 pub backend: Option<SecretBackend>,
58
59 #[serde(default)]
60 pub vault_location: Option<String>,
61
62 #[serde(default)]
63 pub keys_location: Option<String>,
64
65 #[serde(default)]
66 pub key_name: Option<String>,
67
68 #[serde(default)]
69 pub gpg_key_id: Option<String>,
70
71 #[serde(default)]
72 pub secrets_path: Option<Vec<String>>,
73}
74
75impl SecretSettings {
76 pub fn is_empty(&self) -> bool {
77 self.backend.is_none()
78 && self.vault_location.is_none()
79 && self.keys_location.is_none()
80 && self.key_name.is_none()
81 && self.gpg_key_id.is_none()
82 && self.secrets_path.is_none()
83 }
84
85 pub fn merge(&self, overlay: &Self) -> Self {
86 let mut merged = self.clone();
87 if overlay.backend.is_some() {
88 merged.backend = overlay.backend.clone();
89 }
90 if overlay.vault_location.is_some() {
91 merged.vault_location = overlay.vault_location.clone();
92 }
93 if overlay.keys_location.is_some() {
94 merged.keys_location = overlay.keys_location.clone();
95 }
96 if overlay.key_name.is_some() {
97 merged.key_name = overlay.key_name.clone();
98 }
99 if overlay.gpg_key_id.is_some() {
100 merged.gpg_key_id = overlay.gpg_key_id.clone();
101 }
102 if overlay.secrets_path.is_some() {
103 merged.secrets_path = overlay.secrets_path.clone();
104 }
105 merged.with_inferred_backend()
106 }
107
108 pub fn with_inferred_backend(mut self) -> Self {
109 if self.backend.is_none() && self.gpg_key_id.is_some() {
110 self.backend = Some(SecretBackend::Gpg);
111 }
112 self
113 }
114
115 pub fn resolved_backend(&self) -> SecretBackend {
116 infer_secret_backend(self.backend.clone(), self.gpg_key_id.as_deref())
117 }
118
119 pub fn from_legacy(
120 vault_location: Option<String>,
121 keys_location: Option<String>,
122 key_name: Option<String>,
123 gpg_key_id: Option<String>,
124 secrets_path: Vec<String>,
125 ) -> Self {
126 let secrets_path = if secrets_path.is_empty() {
127 None
128 } else {
129 Some(secrets_path)
130 };
131
132 Self {
133 backend: None,
134 vault_location,
135 keys_location,
136 key_name,
137 gpg_key_id,
138 secrets_path,
139 }
140 .with_inferred_backend()
141 }
142}
143
144pub fn merge_optional_secret_settings(
145 base: Option<SecretSettings>,
146 overlay: Option<SecretSettings>,
147) -> Option<SecretSettings> {
148 match (base, overlay) {
149 (Some(base), Some(overlay)) => Some(base.merge(&overlay)),
150 (None, Some(overlay)) => Some(overlay.with_inferred_backend()),
151 (Some(base), None) => Some(base.with_inferred_backend()),
152 (None, None) => None,
153 }
154}
155
156pub fn infer_secret_backend(explicit: Option<SecretBackend>, gpg_key_id: Option<&str>) -> SecretBackend {
157 explicit.unwrap_or_else(|| {
158 if gpg_key_id.is_some() {
159 SecretBackend::Gpg
160 } else {
161 SecretBackend::BuiltInPgp
162 }
163 })
164}
165
166#[derive(Debug, Default, Deserialize, Serialize)]
170pub struct VaultMeta {
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub backend: Option<SecretBackend>,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub keys_location: Option<String>,
178
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub key_name: Option<String>,
182
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub gpg_key_id: Option<String>,
186}
187
188pub fn read_vault_meta(vault_location: &Path) -> Option<VaultMeta> {
189 let content = fs::read_to_string(vault_location.join(VAULT_META_FILE)).ok()?;
190 toml::from_str(&content).ok()
191}
192
193pub fn read_vault_gpg_key_id(vault_location: &Path) -> Option<String> {
196 read_vault_meta(vault_location)?.gpg_key_id
197}
198
199pub fn read_vault_backend(vault_location: &Path) -> Option<SecretBackend> {
200 let meta = read_vault_meta(vault_location)?;
201 meta
202 .backend
203 .or_else(|| meta.gpg_key_id.as_ref().map(|_| SecretBackend::Gpg))
204}
205
206pub fn write_vault_meta(vault_location: &Path, meta: &VaultMeta) -> anyhow::Result<()> {
208 let content = toml::to_string_pretty(&meta).context("Failed to serialize vault metadata")?;
209 let meta_path = vault_location.join(VAULT_META_FILE);
210 let mut file = File::create(&meta_path)?;
211 file.write_all(content.as_bytes())?;
212 file.flush()?;
213 Ok(())
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct SecretConfig {
218 pub backend: SecretBackend,
219 pub vault_location: PathBuf,
220 pub keys_location: PathBuf,
221 pub key_name: String,
222 pub gpg_key_id: Option<String>,
223 pub secrets_path: Vec<String>,
224 pub backend_source: SecretValueSource,
225 pub vault_location_source: SecretValueSource,
226 pub keys_location_source: SecretValueSource,
227 pub key_name_source: SecretValueSource,
228 pub gpg_key_id_source: Option<SecretValueSource>,
229 pub secrets_path_source: Option<SecretValueSource>,
230 pub vault_meta_used: bool,
231}
232
233impl SecretConfig {
234 pub fn with_secrets_path(mut self, secrets_path: Vec<String>, source: Option<SecretValueSource>) -> Self {
235 self.secrets_path = secrets_path;
236 self.secrets_path_source = source;
237 self
238 }
239}
240
241fn pick_setting<'a, T: ?Sized>(
242 cli_value: Option<&'a T>,
243 task_value: Option<&'a T>,
244 root_value: Option<&'a T>,
245 meta_value: Option<&'a T>,
246 default_value: &'a T,
247) -> (&'a T, SecretValueSource) {
248 if let Some(value) = cli_value {
249 return (value, SecretValueSource::Cli);
250 }
251 if let Some(value) = task_value {
252 return (value, SecretValueSource::Task);
253 }
254 if let Some(value) = root_value {
255 return (value, SecretValueSource::Root);
256 }
257 if let Some(value) = meta_value {
258 return (value, SecretValueSource::VaultMeta);
259 }
260 (default_value, SecretValueSource::Default)
261}
262
263pub fn resolve_secret_config(
264 base_dir: &Path,
265 cli_overrides: Option<&SecretSettings>,
266 task_settings: Option<&SecretSettings>,
267 root_settings: Option<&SecretSettings>,
268) -> SecretConfig {
269 let default_vault_location = default_vault_location(base_dir);
270 let cli_vault_location = cli_overrides.and_then(|settings| settings.vault_location.as_deref());
271 let task_vault_location = task_settings.and_then(|settings| settings.vault_location.as_deref());
272 let root_vault_location = root_settings.and_then(|settings| settings.vault_location.as_deref());
273
274 let vault_location = cli_vault_location
275 .or(task_vault_location)
276 .or(root_vault_location)
277 .map(|path| resolve_path(base_dir, path))
278 .unwrap_or_else(|| default_vault_location.clone());
279 let vault_location_source = if cli_vault_location.is_some() {
280 SecretValueSource::Cli
281 } else if task_vault_location.is_some() {
282 SecretValueSource::Task
283 } else if root_vault_location.is_some() {
284 SecretValueSource::Root
285 } else {
286 SecretValueSource::Default
287 };
288
289 let vault_meta = read_vault_meta(&vault_location);
290
291 let default_keys_location = default_keys_location();
292 let default_keys_location_str = default_keys_location.to_string_lossy().to_string();
293 let (keys_location, keys_location_source) = pick_setting(
294 cli_overrides.and_then(|settings| settings.keys_location.as_deref()),
295 task_settings.and_then(|settings| settings.keys_location.as_deref()),
296 root_settings.and_then(|settings| settings.keys_location.as_deref()),
297 vault_meta.as_ref().and_then(|meta| meta.keys_location.as_deref()),
298 default_keys_location_str.as_str(),
299 );
300
301 let default_key_name = String::from("default");
302 let (key_name, key_name_source) = pick_setting(
303 cli_overrides.and_then(|settings| settings.key_name.as_deref()),
304 task_settings.and_then(|settings| settings.key_name.as_deref()),
305 root_settings.and_then(|settings| settings.key_name.as_deref()),
306 vault_meta.as_ref().and_then(|meta| meta.key_name.as_deref()),
307 default_key_name.as_str(),
308 );
309
310 let gpg_key_id = cli_overrides
311 .and_then(|settings| settings.gpg_key_id.as_ref())
312 .map(|value| (value.clone(), SecretValueSource::Cli))
313 .or_else(|| {
314 task_settings
315 .and_then(|settings| settings.gpg_key_id.as_ref())
316 .map(|value| (value.clone(), SecretValueSource::Task))
317 })
318 .or_else(|| {
319 root_settings
320 .and_then(|settings| settings.gpg_key_id.as_ref())
321 .map(|value| (value.clone(), SecretValueSource::Root))
322 })
323 .or_else(|| {
324 vault_meta
325 .as_ref()
326 .and_then(|meta| meta.gpg_key_id.as_ref())
327 .map(|value| (value.clone(), SecretValueSource::VaultMeta))
328 });
329
330 let secrets_path = task_settings
331 .and_then(|settings| {
332 settings
333 .secrets_path
334 .clone()
335 .map(|paths| (paths, SecretValueSource::Task))
336 })
337 .or_else(|| {
338 root_settings.and_then(|settings| {
339 settings
340 .secrets_path
341 .clone()
342 .map(|paths| (paths, SecretValueSource::Root))
343 })
344 });
345
346 let explicit_backend = cli_overrides
347 .and_then(|settings| settings.backend.clone())
348 .or_else(|| task_settings.and_then(|settings| settings.backend.clone()))
349 .or_else(|| root_settings.and_then(|settings| settings.backend.clone()))
350 .or_else(|| vault_meta.as_ref().and_then(|meta| meta.backend.clone()));
351 let backend = infer_secret_backend(
352 explicit_backend,
353 gpg_key_id.as_ref().map(|(value, _)| value.as_str()),
354 );
355 let backend_source = if cli_overrides
356 .and_then(|settings| settings.backend.as_ref())
357 .is_some()
358 {
359 SecretValueSource::Cli
360 } else if task_settings
361 .and_then(|settings| settings.backend.as_ref())
362 .is_some()
363 {
364 SecretValueSource::Task
365 } else if root_settings
366 .and_then(|settings| settings.backend.as_ref())
367 .is_some()
368 {
369 SecretValueSource::Root
370 } else if vault_meta
371 .as_ref()
372 .and_then(|meta| meta.backend.as_ref())
373 .is_some()
374 {
375 SecretValueSource::VaultMeta
376 } else if gpg_key_id.is_some() {
377 gpg_key_id
378 .as_ref()
379 .map(|(_, source)| *source)
380 .unwrap_or(SecretValueSource::Default)
381 } else {
382 SecretValueSource::Default
383 };
384
385 SecretConfig {
386 backend,
387 vault_location,
388 keys_location: resolve_path(base_dir, keys_location),
389 key_name: key_name.to_string(),
390 gpg_key_id: gpg_key_id.as_ref().map(|(value, _)| value.clone()),
391 secrets_path: secrets_path
392 .as_ref()
393 .map(|(paths, _)| paths.clone())
394 .unwrap_or_default(),
395 backend_source,
396 vault_location_source,
397 keys_location_source,
398 key_name_source,
399 gpg_key_id_source: gpg_key_id.as_ref().map(|(_, source)| *source),
400 secrets_path_source: secrets_path.as_ref().map(|(_, source)| *source),
401 vault_meta_used: vault_meta.is_some(),
402 }
403}
404
405pub fn load_secret_values(path: &str, config: &SecretConfig) -> anyhow::Result<Vec<String>> {
406 verify_vault(&config.vault_location)?;
407
408 let secret_path = config.vault_location.join(path);
409 if !secret_path.exists() || !secret_path.is_dir() {
410 anyhow::bail!(
411 "Secret '{}' not found in vault. List available secrets with: mk secrets vault list",
412 path
413 );
414 }
415
416 let mut data_paths = fs::read_dir(&secret_path)?
417 .filter_map(Result::ok)
418 .map(|entry| {
419 if entry.path().is_dir() {
420 entry.path().join("data.asc")
421 } else {
422 entry.path()
423 }
424 })
425 .filter(|path| path.exists() && path.is_file())
426 .collect::<Vec<_>>();
427 data_paths.sort();
428
429 let use_gpg = matches!(config.backend, SecretBackend::Gpg);
430 let signed_secret_key = if !use_gpg {
431 Some(load_secret_key(config)?)
432 } else {
433 check_gpg_available()?;
434 None
435 };
436
437 let mut values = Vec::with_capacity(data_paths.len());
438 for data_path in data_paths {
439 let value = if use_gpg {
440 decrypt_with_gpg(
441 &data_path,
442 config
443 .gpg_key_id
444 .as_deref()
445 .ok_or_else(|| anyhow::anyhow!("GPG backend selected but no gpg_key_id is configured"))?,
446 )?
447 } else {
448 let key = signed_secret_key.as_ref().unwrap();
449 let mut data_file = std::io::BufReader::new(File::open(&data_path)?);
450 let (message, _) = Message::from_armor(&mut data_file)?;
451 let mut decrypted_message = message.decrypt(&pgp::types::Password::empty(), key)?;
452 decrypted_message
453 .as_data_string()
454 .context("Failed to read secret value")?
455 };
456 values.push(value);
457 }
458
459 if values.is_empty() {
460 anyhow::bail!(
461 "No secrets found for path '{}'. List available secrets with: mk secrets vault list",
462 path
463 );
464 }
465
466 Ok(values)
467}
468
469pub fn load_secret_value(path: &str, config: &SecretConfig) -> anyhow::Result<String> {
470 let values = load_secret_values(path, config)?;
471 match values.as_slice() {
472 [value] => Ok(value.clone()),
473 [] => anyhow::bail!(
474 "No secrets found for path '{}'. List available secrets with: mk secrets vault list",
475 path
476 ),
477 _ => anyhow::bail!(
478 "Secret path '{}' resolved to multiple values; use a more specific identifier",
479 path
480 ),
481 }
482}
483
484pub fn list_secret_paths(path_prefix: Option<&str>, config: &SecretConfig) -> anyhow::Result<Vec<String>> {
485 verify_vault(&config.vault_location)?;
486
487 let root = match path_prefix {
488 Some(path_prefix) if !path_prefix.is_empty() => config.vault_location.join(path_prefix),
489 _ => config.vault_location.clone(),
490 };
491
492 if !root.exists() || !root.is_dir() {
493 anyhow::bail!(
494 "Secret prefix '{}' not found in vault. List available secrets with: mk secrets vault list",
495 path_prefix.unwrap_or("<unknown>")
496 );
497 }
498
499 let mut secret_paths = Vec::new();
500 collect_secret_paths(&config.vault_location, &root, &mut secret_paths)?;
501 secret_paths.sort();
502 secret_paths.dedup();
503 Ok(secret_paths)
504}
505
506pub fn load_secret_env(config: &SecretConfig) -> anyhow::Result<HashMap<String, String>> {
507 let mut env_vars = HashMap::new();
508
509 for path in &config.secrets_path {
510 for value in load_secret_values(path, config)? {
511 env_vars.extend(parse_env_contents(&value));
512 }
513 }
514
515 Ok(env_vars)
516}
517
518fn check_gpg_available() -> anyhow::Result<()> {
521 which::which("gpg")
522 .context("gpg is not available in PATH — install GnuPG to use hardware key (YubiKey) support")?;
523 Ok(())
524}
525
526fn default_vault_location(base_dir: &Path) -> PathBuf {
527 resolve_path(base_dir, "./.mk/vault")
528}
529
530pub fn encrypt_with_gpg(gpg_key_id: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
533 check_gpg_available()?;
534 let mut child = Command::new("gpg")
535 .args([
536 "--batch",
537 "--yes",
538 "--armor",
539 "--encrypt",
540 "--recipient",
541 gpg_key_id,
542 ])
543 .stdin(Stdio::piped())
544 .stdout(Stdio::piped())
545 .stderr(Stdio::piped())
546 .spawn()
547 .context("Failed to spawn gpg — is it installed and in PATH?")?;
548
549 if let Some(mut stdin) = child.stdin.take() {
550 stdin
551 .write_all(plaintext)
552 .context("Failed to write plaintext to gpg stdin")?;
553 }
554
555 let output = child
556 .wait_with_output()
557 .context("Failed to wait for gpg encrypt")?;
558 if !output.status.success() {
559 let stderr = String::from_utf8_lossy(&output.stderr);
560 anyhow::bail!("gpg encryption failed: {}", stderr.trim());
561 }
562 Ok(output.stdout)
563}
564
565fn decrypt_with_gpg(data_path: &Path, _gpg_key_id: &str) -> anyhow::Result<String> {
568 let path_str = data_path
569 .to_str()
570 .ok_or_else(|| anyhow::anyhow!("Non-UTF-8 path: {:?}", data_path))?;
571
572 let output = Command::new("gpg")
573 .args(["--batch", "--decrypt", path_str])
574 .stdout(Stdio::piped())
575 .stderr(Stdio::piped())
576 .spawn()
577 .context("Failed to spawn gpg — is it installed and in PATH?")?
578 .wait_with_output()
579 .context("Failed to wait for gpg decrypt")?;
580
581 if !output.status.success() {
582 let stderr = String::from_utf8_lossy(&output.stderr);
583 anyhow::bail!("gpg decryption failed: {}", stderr.trim());
584 }
585 String::from_utf8(output.stdout).context("gpg decrypt output is not valid UTF-8")
586}
587
588fn default_keys_location() -> PathBuf {
589 let home_dir = if cfg!(target_os = "windows") {
590 env::var("USERPROFILE").unwrap_or_else(|_| "./.mk/priv".to_string())
591 } else {
592 env::var("HOME").unwrap_or_else(|_| "./.mk/priv".to_string())
593 };
594
595 let mut path = PathBuf::from(home_dir);
596 path.push(".config");
597 path.push("mk");
598 path.push("priv");
599 path
600}
601
602pub fn verify_vault(vault_location: &Path) -> anyhow::Result<()> {
603 if !vault_location.exists() || !vault_location.is_dir() {
604 anyhow::bail!(
605 "Vault not found at '{}'. Initialize it first with: mk secrets vault init",
606 vault_location.to_utf8().unwrap_or("<non-utf8-path>")
607 );
608 }
609
610 Ok(())
611}
612
613fn load_secret_key(config: &SecretConfig) -> anyhow::Result<SignedSecretKey> {
614 if !config.keys_location.exists() || !config.keys_location.is_dir() {
615 anyhow::bail!(
616 "Keys directory not found at '{}'. Generate a key first with: mk secrets key gen",
617 config.keys_location.to_utf8().unwrap_or("<non-utf8-path>")
618 );
619 }
620
621 let key_path = config.keys_location.join(format!("{}.key", config.key_name));
622 if !key_path.exists() || !key_path.is_file() {
623 anyhow::bail!(
624 "Key '{}' not found in '{}'. Generate it with: mk secrets key gen --name {}",
625 config.key_name,
626 config.keys_location.to_utf8().unwrap_or("<non-utf8-path>"),
627 config.key_name
628 );
629 }
630
631 let mut secret_key_string = File::open(key_path)?;
632 let (signed_secret_key, _) = SignedSecretKey::from_armor_single(&mut secret_key_string)?;
633 signed_secret_key.verify_bindings()?;
634 Ok(signed_secret_key)
635}
636
637fn collect_secret_paths(vault_root: &Path, dir: &Path, secret_paths: &mut Vec<String>) -> anyhow::Result<()> {
638 let data_path = dir.join("data.asc");
639 if data_path.exists() && data_path.is_file() {
640 let relative = dir.strip_prefix(vault_root).map_err(|_| {
641 let dir = dir.to_utf8().unwrap_or("<non-utf8-path>");
642 let vault_root = vault_root.to_utf8().unwrap_or("<non-utf8-path>");
643 anyhow::anyhow!(
644 "Failed to resolve secret path '{}' relative to vault root '{}'",
645 dir,
646 vault_root
647 )
648 })?;
649 secret_paths.push(relative.to_utf8().unwrap_or("<non-utf8-path>").to_string());
650 }
651
652 for entry in fs::read_dir(dir)?.filter_map(Result::ok) {
653 let path = entry.path();
654 if path.is_dir() {
655 collect_secret_paths(vault_root, &path, secret_paths)?;
656 }
657 }
658
659 Ok(())
660}
661
662#[cfg(test)]
663mod tests {
664 use std::fs;
665
666 use assert_fs::TempDir;
667
668 use super::*;
669
670 #[test]
673 fn test_vault_meta_roundtrip() {
674 let dir = TempDir::new().unwrap();
675 let vault_dir = dir.path();
676
677 assert_eq!(read_vault_gpg_key_id(vault_dir), None);
679
680 write_vault_meta(
682 vault_dir,
683 &VaultMeta {
684 backend: Some(SecretBackend::Gpg),
685 keys_location: None,
686 key_name: None,
687 gpg_key_id: Some("ABC123DEF456".to_string()),
688 },
689 )
690 .unwrap();
691
692 assert_eq!(read_vault_gpg_key_id(vault_dir), Some("ABC123DEF456".to_string()));
694 assert_eq!(read_vault_backend(vault_dir), Some(SecretBackend::Gpg));
695 }
696
697 #[test]
698 fn test_vault_meta_overwrite() {
699 let dir = TempDir::new().unwrap();
700 let vault_dir = dir.path();
701
702 write_vault_meta(
703 vault_dir,
704 &VaultMeta {
705 backend: Some(SecretBackend::Gpg),
706 keys_location: None,
707 key_name: None,
708 gpg_key_id: Some("FIRST_KEY".to_string()),
709 },
710 )
711 .unwrap();
712 write_vault_meta(
713 vault_dir,
714 &VaultMeta {
715 backend: Some(SecretBackend::Gpg),
716 keys_location: None,
717 key_name: None,
718 gpg_key_id: Some("SECOND_KEY".to_string()),
719 },
720 )
721 .unwrap();
722
723 assert_eq!(read_vault_gpg_key_id(vault_dir), Some("SECOND_KEY".to_string()));
724 }
725
726 #[test]
727 fn test_read_vault_gpg_key_id_missing_file() {
728 let dir = TempDir::new().unwrap();
729 assert_eq!(read_vault_gpg_key_id(dir.path()), None);
730 }
731
732 #[test]
733 fn test_read_vault_gpg_key_id_invalid_toml() {
734 let dir = TempDir::new().unwrap();
735 fs::write(dir.path().join(VAULT_META_FILE), b"not_valid [ toml {{").unwrap();
736 assert_eq!(read_vault_gpg_key_id(dir.path()), None);
738 }
739
740 #[test]
741 fn test_read_vault_backend_infers_gpg_from_gpg_key_id() {
742 let dir = TempDir::new().unwrap();
743 write_vault_meta(
744 dir.path(),
745 &VaultMeta {
746 backend: None,
747 keys_location: None,
748 key_name: None,
749 gpg_key_id: Some("LEGACY_META_ID".to_string()),
750 },
751 )
752 .unwrap();
753
754 assert_eq!(read_vault_backend(dir.path()), Some(SecretBackend::Gpg));
755 }
756
757 #[test]
758 fn test_verify_vault_accepts_existing_directory() {
759 let dir = TempDir::new().unwrap();
760
761 verify_vault(dir.path()).unwrap();
762 }
763
764 #[test]
765 fn test_verify_vault_rejects_missing_directory() {
766 let dir = TempDir::new().unwrap();
767 let missing = dir.path().join("missing-vault");
768
769 let error = verify_vault(&missing).unwrap_err();
770
771 assert!(
772 error.to_string().contains("Vault not found at '"),
773 "unexpected error: {error}"
774 );
775 assert!(
776 error
777 .to_string()
778 .contains("Initialize it first with: mk secrets vault init"),
779 "unexpected error: {error}"
780 );
781 }
782
783 #[test]
786 fn test_secret_config_explicit_gpg_key_id() {
787 let dir = TempDir::new().unwrap();
788 let vault_dir = dir.path().to_str().unwrap();
789 let base = Path::new(".");
790 let config = resolve_secret_config(
791 base,
792 Some(&SecretSettings {
793 backend: Some(SecretBackend::Gpg),
794 vault_location: Some(vault_dir.to_string()),
795 keys_location: None,
796 key_name: None,
797 gpg_key_id: Some("EXPLICIT_ID".to_string()),
798 secrets_path: None,
799 }),
800 None,
801 None,
802 );
803 assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
804 assert_eq!(config.backend, SecretBackend::Gpg);
805 }
806
807 #[test]
808 fn test_secret_config_gpg_key_id_from_vault_metadata() {
809 let dir = TempDir::new().unwrap();
810 let vault_dir = dir.path().to_str().unwrap();
811 write_vault_meta(
812 dir.path(),
813 &VaultMeta {
814 backend: Some(SecretBackend::Gpg),
815 keys_location: None,
816 key_name: None,
817 gpg_key_id: Some("META_ID".to_string()),
818 },
819 )
820 .unwrap();
821
822 let base = Path::new(".");
823 let config = resolve_secret_config(
824 base,
825 Some(&SecretSettings {
826 backend: None,
827 vault_location: Some(vault_dir.to_string()),
828 keys_location: None,
829 key_name: None,
830 gpg_key_id: None,
831 secrets_path: None,
832 }),
833 None,
834 None,
835 );
836 assert_eq!(config.gpg_key_id, Some("META_ID".to_string()));
837 assert_eq!(config.backend, SecretBackend::Gpg);
838 assert_eq!(config.gpg_key_id_source, Some(SecretValueSource::VaultMeta));
839 }
840
841 #[test]
842 fn test_secret_config_root_settings_allow_vault_metadata_backend() {
843 let dir = TempDir::new().unwrap();
844 let vault_dir = dir.path().to_str().unwrap();
845 write_vault_meta(
846 dir.path(),
847 &VaultMeta {
848 backend: Some(SecretBackend::Gpg),
849 keys_location: None,
850 key_name: None,
851 gpg_key_id: Some("META_ID".to_string()),
852 },
853 )
854 .unwrap();
855
856 let config = resolve_secret_config(
857 Path::new("."),
858 None,
859 None,
860 Some(&SecretSettings {
861 backend: None,
862 vault_location: Some(vault_dir.to_string()),
863 keys_location: None,
864 key_name: None,
865 gpg_key_id: None,
866 secrets_path: None,
867 }),
868 );
869
870 assert_eq!(config.backend, SecretBackend::Gpg);
871 assert_eq!(config.backend_source, SecretValueSource::VaultMeta);
872 assert_eq!(config.gpg_key_id.as_deref(), Some("META_ID"));
873 }
874
875 #[test]
876 fn test_secret_config_legacy_vault_metadata_gpg_key_id_implies_gpg_backend() {
877 let dir = TempDir::new().unwrap();
878 let vault_dir = dir.path().to_str().unwrap();
879 write_vault_meta(
880 dir.path(),
881 &VaultMeta {
882 backend: None,
883 keys_location: None,
884 key_name: None,
885 gpg_key_id: Some("LEGACY_META_ID".to_string()),
886 },
887 )
888 .unwrap();
889
890 let config = resolve_secret_config(
891 Path::new("."),
892 Some(&SecretSettings {
893 backend: None,
894 vault_location: Some(vault_dir.to_string()),
895 keys_location: None,
896 key_name: None,
897 gpg_key_id: None,
898 secrets_path: None,
899 }),
900 None,
901 None,
902 );
903
904 assert_eq!(config.backend, SecretBackend::Gpg);
905 assert_eq!(config.backend_source, SecretValueSource::VaultMeta);
906 assert_eq!(config.gpg_key_id.as_deref(), Some("LEGACY_META_ID"));
907 assert_eq!(config.gpg_key_id_source, Some(SecretValueSource::VaultMeta));
908 }
909
910 #[test]
911 fn test_secret_config_explicit_gpg_key_id_overrides_metadata() {
912 let dir = TempDir::new().unwrap();
913 let vault_dir = dir.path().to_str().unwrap();
914 write_vault_meta(
915 dir.path(),
916 &VaultMeta {
917 backend: Some(SecretBackend::Gpg),
918 keys_location: None,
919 key_name: None,
920 gpg_key_id: Some("META_ID".to_string()),
921 },
922 )
923 .unwrap();
924
925 let base = Path::new(".");
926 let config = resolve_secret_config(
927 base,
928 Some(&SecretSettings {
929 backend: Some(SecretBackend::Gpg),
930 vault_location: Some(vault_dir.to_string()),
931 keys_location: None,
932 key_name: None,
933 gpg_key_id: Some("EXPLICIT_ID".to_string()),
934 secrets_path: None,
935 }),
936 None,
937 None,
938 );
939 assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
941 assert_eq!(config.gpg_key_id_source, Some(SecretValueSource::Cli));
942 }
943
944 #[test]
945 fn test_secret_config_no_gpg_key_id() {
946 let dir = TempDir::new().unwrap();
947 let vault_dir = dir.path().to_str().unwrap();
948 let base = Path::new(".");
949 let config = resolve_secret_config(
951 base,
952 Some(&SecretSettings {
953 backend: None,
954 vault_location: Some(vault_dir.to_string()),
955 keys_location: None,
956 key_name: None,
957 gpg_key_id: None,
958 secrets_path: None,
959 }),
960 None,
961 None,
962 );
963 assert_eq!(config.gpg_key_id, None);
964 assert_eq!(config.backend, SecretBackend::BuiltInPgp);
965 }
966
967 #[test]
968 fn test_secret_config_key_name_default() {
969 let dir = TempDir::new().unwrap();
970 let vault_dir = dir.path().to_str().unwrap();
971 let base = Path::new(".");
972 let config = resolve_secret_config(
973 base,
974 Some(&SecretSettings {
975 backend: None,
976 vault_location: Some(vault_dir.to_string()),
977 keys_location: None,
978 key_name: None,
979 gpg_key_id: None,
980 secrets_path: None,
981 }),
982 None,
983 None,
984 );
985 assert_eq!(config.key_name, "default");
986 }
987
988 #[test]
989 fn test_secret_config_key_name_custom() {
990 let dir = TempDir::new().unwrap();
991 let vault_dir = dir.path().to_str().unwrap();
992 let base = Path::new(".");
993 let config = resolve_secret_config(
994 base,
995 Some(&SecretSettings {
996 backend: None,
997 vault_location: Some(vault_dir.to_string()),
998 keys_location: None,
999 key_name: Some("mykey".to_string()),
1000 gpg_key_id: None,
1001 secrets_path: None,
1002 }),
1003 None,
1004 None,
1005 );
1006 assert_eq!(config.key_name, "mykey");
1007 }
1008
1009 #[test]
1010 fn test_secret_settings_merge_prefers_overlay() {
1011 let base = SecretSettings {
1012 backend: Some(SecretBackend::BuiltInPgp),
1013 vault_location: Some("root-vault".to_string()),
1014 keys_location: Some("root-keys".to_string()),
1015 key_name: Some("root".to_string()),
1016 gpg_key_id: None,
1017 secrets_path: Some(vec!["root/path".to_string()]),
1018 };
1019 let overlay = SecretSettings {
1020 backend: Some(SecretBackend::Gpg),
1021 vault_location: None,
1022 keys_location: None,
1023 key_name: None,
1024 gpg_key_id: Some("KEYID".to_string()),
1025 secrets_path: Some(vec!["task/path".to_string()]),
1026 };
1027
1028 let merged = base.merge(&overlay);
1029 assert_eq!(merged.backend, Some(SecretBackend::Gpg));
1030 assert_eq!(merged.vault_location.as_deref(), Some("root-vault"));
1031 assert_eq!(merged.gpg_key_id.as_deref(), Some("KEYID"));
1032 assert_eq!(merged.secrets_path, Some(vec!["task/path".to_string()]));
1033 }
1034
1035 #[test]
1038 fn test_vault_meta_toml_no_gpg_key_id() {
1039 let meta = VaultMeta {
1041 backend: None,
1042 keys_location: None,
1043 key_name: None,
1044 gpg_key_id: None,
1045 };
1046 let s = toml::to_string_pretty(&meta).unwrap();
1047 assert!(!s.contains("gpg_key_id"), "unexpected field in: {s}");
1048 }
1049
1050 #[test]
1051 fn test_vault_meta_toml_with_gpg_key_id() {
1052 let meta = VaultMeta {
1053 backend: Some(SecretBackend::Gpg),
1054 keys_location: Some("./keys".to_string()),
1055 key_name: Some("vault".to_string()),
1056 gpg_key_id: Some("FINGERPRINT".to_string()),
1057 };
1058 let s = toml::to_string_pretty(&meta).unwrap();
1059 assert!(s.contains("backend"), "field missing from: {s}");
1060 assert!(s.contains("keys_location"), "field missing from: {s}");
1061 assert!(s.contains("key_name"), "field missing from: {s}");
1062 assert!(s.contains("gpg_key_id"), "field missing from: {s}");
1063 assert!(s.contains("FINGERPRINT"));
1064 }
1065}