Skip to main content

mk_lib/
secrets.rs

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/// Metadata stored inside a vault directory that describes how the vault should be accessed.
167/// Written by `mk secrets vault init --gpg-key-id` so subsequent commands
168/// (store, show, export, …) pick up the GPG key automatically without flags.
169#[derive(Debug, Default, Deserialize, Serialize)]
170pub struct VaultMeta {
171  /// Explicit backend used to access this vault.
172  #[serde(skip_serializing_if = "Option::is_none")]
173  pub backend: Option<SecretBackend>,
174
175  /// Path to key directory for built-in PGP vaults.
176  #[serde(skip_serializing_if = "Option::is_none")]
177  pub keys_location: Option<String>,
178
179  /// Key name for built-in PGP vaults.
180  #[serde(skip_serializing_if = "Option::is_none")]
181  pub key_name: Option<String>,
182
183  /// GPG key ID or fingerprint used to encrypt/decrypt secrets in this vault
184  #[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
193/// Read the GPG key ID stored in a vault's metadata file, if present.
194/// Returns `None` when the file does not exist or cannot be parsed.
195pub 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
206/// Write (or overwrite) the vault's metadata file with the supplied settings.
207pub 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
518/// Checks that the `gpg` binary is available in PATH, returning a clear error if not.
519/// Called early when any GPG-backend vault operation is attempted.
520fn 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
530/// Encrypt `plaintext` using the system `gpg` binary for the given key ID or fingerprint.
531/// The output is ASCII-armored PGP data suitable for storing as a `data.asc` vault file.
532pub 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
565/// Decrypt a vault `data.asc` file using the system `gpg` binary.
566/// GPG-agent handles PIN/passphrase prompts automatically (including YubiKey via pinentry).
567fn 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  // ── VaultMeta / write_vault_meta / read_vault_gpg_key_id ──────────────────
671
672  #[test]
673  fn test_vault_meta_roundtrip() {
674    let dir = TempDir::new().unwrap();
675    let vault_dir = dir.path();
676
677    // Nothing written yet → returns None
678    assert_eq!(read_vault_gpg_key_id(vault_dir), None);
679
680    // Write a key ID
681    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    // Read it back
693    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    // Should return None gracefully, no panic
737    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  // ── resolve_secret_config ────────────────────────────────────────────────
784
785  #[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    // Explicit arg wins over metadata
940    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    // Empty vault dir — no .vault-meta.toml written
950    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  // ── VaultMeta serialization ───────────────────────────────────────────────
1036
1037  #[test]
1038  fn test_vault_meta_toml_no_gpg_key_id() {
1039    // When gpg_key_id is None, the field is skipped in the TOML output
1040    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}