1
use std::env;
2
use std::fs::{
3
  self,
4
  File,
5
};
6
use std::io::Write as _;
7
use std::path::{
8
  Path,
9
  PathBuf,
10
};
11
use std::process::{
12
  Command,
13
  Stdio,
14
};
15

            
16
use anyhow::Context as _;
17
use hashbrown::HashMap;
18
use pgp::composed::{
19
  Deserializable as _,
20
  Message,
21
  SignedSecretKey,
22
};
23
use schemars::JsonSchema;
24
use serde::{
25
  Deserialize,
26
  Serialize,
27
};
28

            
29
use crate::file::ToUtf8 as _;
30
use crate::utils::{
31
  parse_env_contents,
32
  resolve_path,
33
};
34

            
35
const VAULT_META_FILE: &str = ".vault-meta.toml";
36

            
37
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38
pub 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")]
48
pub enum SecretBackend {
49
  #[default]
50
  BuiltInPgp,
51
  Gpg,
52
}
53

            
54
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
55
pub 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

            
75
impl SecretSettings {
76
2524
  pub fn is_empty(&self) -> bool {
77
2524
    self.backend.is_none()
78
2496
      && self.vault_location.is_none()
79
2490
      && self.keys_location.is_none()
80
2490
      && self.key_name.is_none()
81
2490
      && self.gpg_key_id.is_none()
82
2490
      && self.secrets_path.is_none()
83
2524
  }
84

            
85
26
  pub fn merge(&self, overlay: &Self) -> Self {
86
26
    let mut merged = self.clone();
87
26
    if overlay.backend.is_some() {
88
24
      merged.backend = overlay.backend.clone();
89
24
    }
90
26
    if overlay.vault_location.is_some() {
91
4
      merged.vault_location = overlay.vault_location.clone();
92
22
    }
93
26
    if overlay.keys_location.is_some() {
94
8
      merged.keys_location = overlay.keys_location.clone();
95
18
    }
96
26
    if overlay.key_name.is_some() {
97
8
      merged.key_name = overlay.key_name.clone();
98
18
    }
99
26
    if overlay.gpg_key_id.is_some() {
100
16
      merged.gpg_key_id = overlay.gpg_key_id.clone();
101
16
    }
102
26
    if overlay.secrets_path.is_some() {
103
6
      merged.secrets_path = overlay.secrets_path.clone();
104
20
    }
105
26
    merged.with_inferred_backend()
106
26
  }
107

            
108
6696
  pub fn with_inferred_backend(mut self) -> Self {
109
6696
    if self.backend.is_none() && self.gpg_key_id.is_some() {
110
24
      self.backend = Some(SecretBackend::Gpg);
111
6672
    }
112
6696
    self
113
6696
  }
114

            
115
10
  pub fn resolved_backend(&self) -> SecretBackend {
116
10
    infer_secret_backend(self.backend.clone(), self.gpg_key_id.as_deref())
117
10
  }
118

            
119
4354
  pub fn from_legacy(
120
4354
    vault_location: Option<String>,
121
4354
    keys_location: Option<String>,
122
4354
    key_name: Option<String>,
123
4354
    gpg_key_id: Option<String>,
124
4354
    secrets_path: Vec<String>,
125
4354
  ) -> Self {
126
4354
    let secrets_path = if secrets_path.is_empty() {
127
4240
      None
128
    } else {
129
114
      Some(secrets_path)
130
    };
131

            
132
4354
    Self {
133
4354
      backend: None,
134
4354
      vault_location,
135
4354
      keys_location,
136
4354
      key_name,
137
4354
      gpg_key_id,
138
4354
      secrets_path,
139
4354
    }
140
4354
    .with_inferred_backend()
141
4354
  }
142
}
143

            
144
2340
pub fn merge_optional_secret_settings(
145
2340
  base: Option<SecretSettings>,
146
2340
  overlay: Option<SecretSettings>,
147
2340
) -> Option<SecretSettings> {
148
2340
  match (base, overlay) {
149
24
    (Some(base), Some(overlay)) => Some(base.merge(&overlay)),
150
    (None, Some(overlay)) => Some(overlay.with_inferred_backend()),
151
2316
    (Some(base), None) => Some(base.with_inferred_backend()),
152
    (None, None) => None,
153
  }
154
2340
}
155

            
156
94
pub fn infer_secret_backend(explicit: Option<SecretBackend>, gpg_key_id: Option<&str>) -> SecretBackend {
157
94
  explicit.unwrap_or_else(|| {
158
66
    if gpg_key_id.is_some() {
159
2
      SecretBackend::Gpg
160
    } else {
161
64
      SecretBackend::BuiltInPgp
162
    }
163
66
  })
164
94
}
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)]
170
pub 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

            
188
114
pub fn read_vault_meta(vault_location: &Path) -> Option<VaultMeta> {
189
114
  let content = fs::read_to_string(vault_location.join(VAULT_META_FILE)).ok()?;
190
46
  toml::from_str(&content).ok()
191
114
}
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.
195
26
pub fn read_vault_gpg_key_id(vault_location: &Path) -> Option<String> {
196
26
  read_vault_meta(vault_location)?.gpg_key_id
197
26
}
198

            
199
4
pub fn read_vault_backend(vault_location: &Path) -> Option<SecretBackend> {
200
4
  let meta = read_vault_meta(vault_location)?;
201
4
  meta
202
4
    .backend
203
4
    .or_else(|| meta.gpg_key_id.as_ref().map(|_| SecretBackend::Gpg))
204
4
}
205

            
206
/// Write (or overwrite) the vault's metadata file with the supplied settings.
207
16
pub fn write_vault_meta(vault_location: &Path, meta: &VaultMeta) -> anyhow::Result<()> {
208
16
  let content = toml::to_string_pretty(&meta).context("Failed to serialize vault metadata")?;
209
16
  let meta_path = vault_location.join(VAULT_META_FILE);
210
16
  let mut file = File::create(&meta_path)?;
211
16
  file.write_all(content.as_bytes())?;
212
16
  file.flush()?;
213
16
  Ok(())
214
16
}
215

            
216
#[derive(Debug, Clone, PartialEq, Eq)]
217
pub 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

            
233
impl 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

            
241
168
fn pick_setting<'a, T: ?Sized>(
242
168
  cli_value: Option<&'a T>,
243
168
  task_value: Option<&'a T>,
244
168
  root_value: Option<&'a T>,
245
168
  meta_value: Option<&'a T>,
246
168
  default_value: &'a T,
247
168
) -> (&'a T, SecretValueSource) {
248
168
  if let Some(value) = cli_value {
249
2
    return (value, SecretValueSource::Cli);
250
166
  }
251
166
  if let Some(value) = task_value {
252
    return (value, SecretValueSource::Task);
253
166
  }
254
166
  if let Some(value) = root_value {
255
    return (value, SecretValueSource::Root);
256
166
  }
257
166
  if let Some(value) = meta_value {
258
    return (value, SecretValueSource::VaultMeta);
259
166
  }
260
166
  (default_value, SecretValueSource::Default)
261
168
}
262

            
263
84
pub fn resolve_secret_config(
264
84
  base_dir: &Path,
265
84
  cli_overrides: Option<&SecretSettings>,
266
84
  task_settings: Option<&SecretSettings>,
267
84
  root_settings: Option<&SecretSettings>,
268
84
) -> SecretConfig {
269
84
  let default_vault_location = default_vault_location(base_dir);
270
84
  let cli_vault_location = cli_overrides.and_then(|settings| settings.vault_location.as_deref());
271
84
  let task_vault_location = task_settings.and_then(|settings| settings.vault_location.as_deref());
272
84
  let root_vault_location = root_settings.and_then(|settings| settings.vault_location.as_deref());
273

            
274
84
  let vault_location = cli_vault_location
275
84
    .or(task_vault_location)
276
84
    .or(root_vault_location)
277
84
    .map(|path| resolve_path(base_dir, path))
278
84
    .unwrap_or_else(|| default_vault_location.clone());
279
84
  let vault_location_source = if cli_vault_location.is_some() {
280
14
    SecretValueSource::Cli
281
70
  } else if task_vault_location.is_some() {
282
    SecretValueSource::Task
283
70
  } else if root_vault_location.is_some() {
284
2
    SecretValueSource::Root
285
  } else {
286
68
    SecretValueSource::Default
287
  };
288

            
289
84
  let vault_meta = read_vault_meta(&vault_location);
290

            
291
84
  let default_keys_location = default_keys_location();
292
84
  let default_keys_location_str = default_keys_location.to_string_lossy().to_string();
293
84
  let (keys_location, keys_location_source) = pick_setting(
294
84
    cli_overrides.and_then(|settings| settings.keys_location.as_deref()),
295
84
    task_settings.and_then(|settings| settings.keys_location.as_deref()),
296
84
    root_settings.and_then(|settings| settings.keys_location.as_deref()),
297
84
    vault_meta.as_ref().and_then(|meta| meta.keys_location.as_deref()),
298
84
    default_keys_location_str.as_str(),
299
  );
300

            
301
84
  let default_key_name = String::from("default");
302
84
  let (key_name, key_name_source) = pick_setting(
303
84
    cli_overrides.and_then(|settings| settings.key_name.as_deref()),
304
84
    task_settings.and_then(|settings| settings.key_name.as_deref()),
305
84
    root_settings.and_then(|settings| settings.key_name.as_deref()),
306
84
    vault_meta.as_ref().and_then(|meta| meta.key_name.as_deref()),
307
84
    default_key_name.as_str(),
308
  );
309

            
310
84
  let gpg_key_id = cli_overrides
311
84
    .and_then(|settings| settings.gpg_key_id.as_ref())
312
84
    .map(|value| (value.clone(), SecretValueSource::Cli))
313
84
    .or_else(|| {
314
80
      task_settings
315
80
        .and_then(|settings| settings.gpg_key_id.as_ref())
316
80
        .map(|value| (value.clone(), SecretValueSource::Task))
317
80
    })
318
84
    .or_else(|| {
319
80
      root_settings
320
80
        .and_then(|settings| settings.gpg_key_id.as_ref())
321
80
        .map(|value| (value.clone(), SecretValueSource::Root))
322
80
    })
323
84
    .or_else(|| {
324
80
      vault_meta
325
80
        .as_ref()
326
80
        .and_then(|meta| meta.gpg_key_id.as_ref())
327
80
        .map(|value| (value.clone(), SecretValueSource::VaultMeta))
328
80
    });
329

            
330
84
  let secrets_path = task_settings
331
84
    .and_then(|settings| {
332
      settings
333
        .secrets_path
334
        .clone()
335
        .map(|paths| (paths, SecretValueSource::Task))
336
    })
337
84
    .or_else(|| {
338
84
      root_settings.and_then(|settings| {
339
2
        settings
340
2
          .secrets_path
341
2
          .clone()
342
2
          .map(|paths| (paths, SecretValueSource::Root))
343
2
      })
344
84
    });
345

            
346
84
  let explicit_backend = cli_overrides
347
84
    .and_then(|settings| settings.backend.clone())
348
84
    .or_else(|| task_settings.and_then(|settings| settings.backend.clone()))
349
84
    .or_else(|| root_settings.and_then(|settings| settings.backend.clone()))
350
84
    .or_else(|| vault_meta.as_ref().and_then(|meta| meta.backend.clone()));
351
84
  let backend = infer_secret_backend(
352
84
    explicit_backend,
353
84
    gpg_key_id.as_ref().map(|(value, _)| value.as_str()),
354
  );
355
84
  let backend_source = if cli_overrides
356
84
    .and_then(|settings| settings.backend.as_ref())
357
84
    .is_some()
358
  {
359
4
    SecretValueSource::Cli
360
80
  } else if task_settings
361
80
    .and_then(|settings| settings.backend.as_ref())
362
80
    .is_some()
363
  {
364
    SecretValueSource::Task
365
80
  } else if root_settings
366
80
    .and_then(|settings| settings.backend.as_ref())
367
80
    .is_some()
368
  {
369
    SecretValueSource::Root
370
80
  } else if vault_meta
371
80
    .as_ref()
372
80
    .and_then(|meta| meta.backend.as_ref())
373
80
    .is_some()
374
  {
375
16
    SecretValueSource::VaultMeta
376
64
  } else if gpg_key_id.is_some() {
377
2
    gpg_key_id
378
2
      .as_ref()
379
2
      .map(|(_, source)| *source)
380
2
      .unwrap_or(SecretValueSource::Default)
381
  } else {
382
62
    SecretValueSource::Default
383
  };
384

            
385
  SecretConfig {
386
84
    backend,
387
84
    vault_location,
388
84
    keys_location: resolve_path(base_dir, keys_location),
389
84
    key_name: key_name.to_string(),
390
84
    gpg_key_id: gpg_key_id.as_ref().map(|(value, _)| value.clone()),
391
84
    secrets_path: secrets_path
392
84
      .as_ref()
393
84
      .map(|(paths, _)| paths.clone())
394
84
      .unwrap_or_default(),
395
84
    backend_source,
396
84
    vault_location_source,
397
84
    keys_location_source,
398
84
    key_name_source,
399
84
    gpg_key_id_source: gpg_key_id.as_ref().map(|(_, source)| *source),
400
84
    secrets_path_source: secrets_path.as_ref().map(|(_, source)| *source),
401
84
    vault_meta_used: vault_meta.is_some(),
402
  }
403
84
}
404

            
405
pub 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

            
469
pub 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

            
484
pub 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

            
506
68
pub fn load_secret_env(config: &SecretConfig) -> anyhow::Result<HashMap<String, String>> {
507
68
  let mut env_vars = HashMap::new();
508

            
509
68
  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
68
  Ok(env_vars)
516
68
}
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.
520
fn 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

            
526
84
fn default_vault_location(base_dir: &Path) -> PathBuf {
527
84
  resolve_path(base_dir, "./.mk/vault")
528
84
}
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.
532
pub 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).
567
fn 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

            
588
84
fn default_keys_location() -> PathBuf {
589
84
  let home_dir = if cfg!(target_os = "windows") {
590
    env::var("USERPROFILE").unwrap_or_else(|_| "./.mk/priv".to_string())
591
  } else {
592
84
    env::var("HOME").unwrap_or_else(|_| "./.mk/priv".to_string())
593
  };
594

            
595
84
  let mut path = PathBuf::from(home_dir);
596
84
  path.push(".config");
597
84
  path.push("mk");
598
84
  path.push("priv");
599
84
  path
600
84
}
601

            
602
4
pub fn verify_vault(vault_location: &Path) -> anyhow::Result<()> {
603
4
  if !vault_location.exists() || !vault_location.is_dir() {
604
2
    anyhow::bail!(
605
      "Vault not found at '{}'. Initialize it first with: mk secrets vault init",
606
2
      vault_location.to_utf8().unwrap_or("<non-utf8-path>")
607
    );
608
2
  }
609

            
610
2
  Ok(())
611
4
}
612

            
613
fn 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

            
637
fn 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(render_secret_path(relative));
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
2
fn render_secret_path(path: &Path) -> String {
663
2
  path
664
2
    .components()
665
4
    .map(|component| component.as_os_str().to_string_lossy().into_owned())
666
2
    .collect::<Vec<_>>()
667
2
    .join("/")
668
2
}
669

            
670
#[cfg(test)]
671
mod tests {
672
  use std::fs;
673

            
674
  use assert_fs::TempDir;
675

            
676
  use super::*;
677

            
678
  // ── VaultMeta / write_vault_meta / read_vault_gpg_key_id ──────────────────
679

            
680
  #[test]
681
2
  fn test_vault_meta_roundtrip() {
682
2
    let dir = TempDir::new().unwrap();
683
2
    let vault_dir = dir.path();
684

            
685
    // Nothing written yet → returns None
686
2
    assert_eq!(read_vault_gpg_key_id(vault_dir), None);
687

            
688
    // Write a key ID
689
2
    write_vault_meta(
690
2
      vault_dir,
691
2
      &VaultMeta {
692
2
        backend: Some(SecretBackend::Gpg),
693
2
        keys_location: None,
694
2
        key_name: None,
695
2
        gpg_key_id: Some("ABC123DEF456".to_string()),
696
2
      },
697
    )
698
2
    .unwrap();
699

            
700
    // Read it back
701
2
    assert_eq!(read_vault_gpg_key_id(vault_dir), Some("ABC123DEF456".to_string()));
702
2
    assert_eq!(read_vault_backend(vault_dir), Some(SecretBackend::Gpg));
703
2
  }
704

            
705
  #[test]
706
2
  fn test_vault_meta_overwrite() {
707
2
    let dir = TempDir::new().unwrap();
708
2
    let vault_dir = dir.path();
709

            
710
2
    write_vault_meta(
711
2
      vault_dir,
712
2
      &VaultMeta {
713
2
        backend: Some(SecretBackend::Gpg),
714
2
        keys_location: None,
715
2
        key_name: None,
716
2
        gpg_key_id: Some("FIRST_KEY".to_string()),
717
2
      },
718
    )
719
2
    .unwrap();
720
2
    write_vault_meta(
721
2
      vault_dir,
722
2
      &VaultMeta {
723
2
        backend: Some(SecretBackend::Gpg),
724
2
        keys_location: None,
725
2
        key_name: None,
726
2
        gpg_key_id: Some("SECOND_KEY".to_string()),
727
2
      },
728
    )
729
2
    .unwrap();
730

            
731
2
    assert_eq!(read_vault_gpg_key_id(vault_dir), Some("SECOND_KEY".to_string()));
732
2
  }
733

            
734
  #[test]
735
2
  fn test_render_secret_path_uses_forward_slashes() {
736
2
    let path = Path::new("app").join("token");
737
2
    assert_eq!(render_secret_path(&path), "app/token");
738
2
  }
739

            
740
  #[test]
741
2
  fn test_read_vault_gpg_key_id_missing_file() {
742
2
    let dir = TempDir::new().unwrap();
743
2
    assert_eq!(read_vault_gpg_key_id(dir.path()), None);
744
2
  }
745

            
746
  #[test]
747
2
  fn test_read_vault_gpg_key_id_invalid_toml() {
748
2
    let dir = TempDir::new().unwrap();
749
2
    fs::write(dir.path().join(VAULT_META_FILE), b"not_valid [ toml {{").unwrap();
750
    // Should return None gracefully, no panic
751
2
    assert_eq!(read_vault_gpg_key_id(dir.path()), None);
752
2
  }
753

            
754
  #[test]
755
2
  fn test_read_vault_backend_infers_gpg_from_gpg_key_id() {
756
2
    let dir = TempDir::new().unwrap();
757
2
    write_vault_meta(
758
2
      dir.path(),
759
2
      &VaultMeta {
760
2
        backend: None,
761
2
        keys_location: None,
762
2
        key_name: None,
763
2
        gpg_key_id: Some("LEGACY_META_ID".to_string()),
764
2
      },
765
    )
766
2
    .unwrap();
767

            
768
2
    assert_eq!(read_vault_backend(dir.path()), Some(SecretBackend::Gpg));
769
2
  }
770

            
771
  #[test]
772
2
  fn test_verify_vault_accepts_existing_directory() {
773
2
    let dir = TempDir::new().unwrap();
774

            
775
2
    verify_vault(dir.path()).unwrap();
776
2
  }
777

            
778
  #[test]
779
2
  fn test_verify_vault_rejects_missing_directory() {
780
2
    let dir = TempDir::new().unwrap();
781
2
    let missing = dir.path().join("missing-vault");
782

            
783
2
    let error = verify_vault(&missing).unwrap_err();
784

            
785
2
    assert!(
786
2
      error.to_string().contains("Vault not found at '"),
787
      "unexpected error: {error}"
788
    );
789
2
    assert!(
790
2
      error
791
2
        .to_string()
792
2
        .contains("Initialize it first with: mk secrets vault init"),
793
      "unexpected error: {error}"
794
    );
795
2
  }
796

            
797
  // ── resolve_secret_config ────────────────────────────────────────────────
798

            
799
  #[test]
800
2
  fn test_secret_config_explicit_gpg_key_id() {
801
2
    let dir = TempDir::new().unwrap();
802
2
    let vault_dir = dir.path().to_str().unwrap();
803
2
    let base = Path::new(".");
804
2
    let config = resolve_secret_config(
805
2
      base,
806
2
      Some(&SecretSettings {
807
2
        backend: Some(SecretBackend::Gpg),
808
2
        vault_location: Some(vault_dir.to_string()),
809
2
        keys_location: None,
810
2
        key_name: None,
811
2
        gpg_key_id: Some("EXPLICIT_ID".to_string()),
812
2
        secrets_path: None,
813
2
      }),
814
2
      None,
815
2
      None,
816
    );
817
2
    assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
818
2
    assert_eq!(config.backend, SecretBackend::Gpg);
819
2
  }
820

            
821
  #[test]
822
2
  fn test_secret_config_gpg_key_id_from_vault_metadata() {
823
2
    let dir = TempDir::new().unwrap();
824
2
    let vault_dir = dir.path().to_str().unwrap();
825
2
    write_vault_meta(
826
2
      dir.path(),
827
2
      &VaultMeta {
828
2
        backend: Some(SecretBackend::Gpg),
829
2
        keys_location: None,
830
2
        key_name: None,
831
2
        gpg_key_id: Some("META_ID".to_string()),
832
2
      },
833
    )
834
2
    .unwrap();
835

            
836
2
    let base = Path::new(".");
837
2
    let config = resolve_secret_config(
838
2
      base,
839
2
      Some(&SecretSettings {
840
2
        backend: None,
841
2
        vault_location: Some(vault_dir.to_string()),
842
2
        keys_location: None,
843
2
        key_name: None,
844
2
        gpg_key_id: None,
845
2
        secrets_path: None,
846
2
      }),
847
2
      None,
848
2
      None,
849
    );
850
2
    assert_eq!(config.gpg_key_id, Some("META_ID".to_string()));
851
2
    assert_eq!(config.backend, SecretBackend::Gpg);
852
2
    assert_eq!(config.gpg_key_id_source, Some(SecretValueSource::VaultMeta));
853
2
  }
854

            
855
  #[test]
856
2
  fn test_secret_config_root_settings_allow_vault_metadata_backend() {
857
2
    let dir = TempDir::new().unwrap();
858
2
    let vault_dir = dir.path().to_str().unwrap();
859
2
    write_vault_meta(
860
2
      dir.path(),
861
2
      &VaultMeta {
862
2
        backend: Some(SecretBackend::Gpg),
863
2
        keys_location: None,
864
2
        key_name: None,
865
2
        gpg_key_id: Some("META_ID".to_string()),
866
2
      },
867
    )
868
2
    .unwrap();
869

            
870
2
    let config = resolve_secret_config(
871
2
      Path::new("."),
872
2
      None,
873
2
      None,
874
2
      Some(&SecretSettings {
875
2
        backend: None,
876
2
        vault_location: Some(vault_dir.to_string()),
877
2
        keys_location: None,
878
2
        key_name: None,
879
2
        gpg_key_id: None,
880
2
        secrets_path: None,
881
2
      }),
882
    );
883

            
884
2
    assert_eq!(config.backend, SecretBackend::Gpg);
885
2
    assert_eq!(config.backend_source, SecretValueSource::VaultMeta);
886
2
    assert_eq!(config.gpg_key_id.as_deref(), Some("META_ID"));
887
2
  }
888

            
889
  #[test]
890
2
  fn test_secret_config_legacy_vault_metadata_gpg_key_id_implies_gpg_backend() {
891
2
    let dir = TempDir::new().unwrap();
892
2
    let vault_dir = dir.path().to_str().unwrap();
893
2
    write_vault_meta(
894
2
      dir.path(),
895
2
      &VaultMeta {
896
2
        backend: None,
897
2
        keys_location: None,
898
2
        key_name: None,
899
2
        gpg_key_id: Some("LEGACY_META_ID".to_string()),
900
2
      },
901
    )
902
2
    .unwrap();
903

            
904
2
    let config = resolve_secret_config(
905
2
      Path::new("."),
906
2
      Some(&SecretSettings {
907
2
        backend: None,
908
2
        vault_location: Some(vault_dir.to_string()),
909
2
        keys_location: None,
910
2
        key_name: None,
911
2
        gpg_key_id: None,
912
2
        secrets_path: None,
913
2
      }),
914
2
      None,
915
2
      None,
916
    );
917

            
918
2
    assert_eq!(config.backend, SecretBackend::Gpg);
919
2
    assert_eq!(config.backend_source, SecretValueSource::VaultMeta);
920
2
    assert_eq!(config.gpg_key_id.as_deref(), Some("LEGACY_META_ID"));
921
2
    assert_eq!(config.gpg_key_id_source, Some(SecretValueSource::VaultMeta));
922
2
  }
923

            
924
  #[test]
925
2
  fn test_secret_config_explicit_gpg_key_id_overrides_metadata() {
926
2
    let dir = TempDir::new().unwrap();
927
2
    let vault_dir = dir.path().to_str().unwrap();
928
2
    write_vault_meta(
929
2
      dir.path(),
930
2
      &VaultMeta {
931
2
        backend: Some(SecretBackend::Gpg),
932
2
        keys_location: None,
933
2
        key_name: None,
934
2
        gpg_key_id: Some("META_ID".to_string()),
935
2
      },
936
    )
937
2
    .unwrap();
938

            
939
2
    let base = Path::new(".");
940
2
    let config = resolve_secret_config(
941
2
      base,
942
2
      Some(&SecretSettings {
943
2
        backend: Some(SecretBackend::Gpg),
944
2
        vault_location: Some(vault_dir.to_string()),
945
2
        keys_location: None,
946
2
        key_name: None,
947
2
        gpg_key_id: Some("EXPLICIT_ID".to_string()),
948
2
        secrets_path: None,
949
2
      }),
950
2
      None,
951
2
      None,
952
    );
953
    // Explicit arg wins over metadata
954
2
    assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
955
2
    assert_eq!(config.gpg_key_id_source, Some(SecretValueSource::Cli));
956
2
  }
957

            
958
  #[test]
959
2
  fn test_secret_config_no_gpg_key_id() {
960
2
    let dir = TempDir::new().unwrap();
961
2
    let vault_dir = dir.path().to_str().unwrap();
962
2
    let base = Path::new(".");
963
    // Empty vault dir — no .vault-meta.toml written
964
2
    let config = resolve_secret_config(
965
2
      base,
966
2
      Some(&SecretSettings {
967
2
        backend: None,
968
2
        vault_location: Some(vault_dir.to_string()),
969
2
        keys_location: None,
970
2
        key_name: None,
971
2
        gpg_key_id: None,
972
2
        secrets_path: None,
973
2
      }),
974
2
      None,
975
2
      None,
976
    );
977
2
    assert_eq!(config.gpg_key_id, None);
978
2
    assert_eq!(config.backend, SecretBackend::BuiltInPgp);
979
2
  }
980

            
981
  #[test]
982
2
  fn test_secret_config_key_name_default() {
983
2
    let dir = TempDir::new().unwrap();
984
2
    let vault_dir = dir.path().to_str().unwrap();
985
2
    let base = Path::new(".");
986
2
    let config = resolve_secret_config(
987
2
      base,
988
2
      Some(&SecretSettings {
989
2
        backend: None,
990
2
        vault_location: Some(vault_dir.to_string()),
991
2
        keys_location: None,
992
2
        key_name: None,
993
2
        gpg_key_id: None,
994
2
        secrets_path: None,
995
2
      }),
996
2
      None,
997
2
      None,
998
    );
999
2
    assert_eq!(config.key_name, "default");
2
  }
  #[test]
2
  fn test_secret_config_key_name_custom() {
2
    let dir = TempDir::new().unwrap();
2
    let vault_dir = dir.path().to_str().unwrap();
2
    let base = Path::new(".");
2
    let config = resolve_secret_config(
2
      base,
2
      Some(&SecretSettings {
2
        backend: None,
2
        vault_location: Some(vault_dir.to_string()),
2
        keys_location: None,
2
        key_name: Some("mykey".to_string()),
2
        gpg_key_id: None,
2
        secrets_path: None,
2
      }),
2
      None,
2
      None,
    );
2
    assert_eq!(config.key_name, "mykey");
2
  }
  #[test]
2
  fn test_secret_settings_merge_prefers_overlay() {
2
    let base = SecretSettings {
2
      backend: Some(SecretBackend::BuiltInPgp),
2
      vault_location: Some("root-vault".to_string()),
2
      keys_location: Some("root-keys".to_string()),
2
      key_name: Some("root".to_string()),
2
      gpg_key_id: None,
2
      secrets_path: Some(vec!["root/path".to_string()]),
2
    };
2
    let overlay = SecretSettings {
2
      backend: Some(SecretBackend::Gpg),
2
      vault_location: None,
2
      keys_location: None,
2
      key_name: None,
2
      gpg_key_id: Some("KEYID".to_string()),
2
      secrets_path: Some(vec!["task/path".to_string()]),
2
    };
2
    let merged = base.merge(&overlay);
2
    assert_eq!(merged.backend, Some(SecretBackend::Gpg));
2
    assert_eq!(merged.vault_location.as_deref(), Some("root-vault"));
2
    assert_eq!(merged.gpg_key_id.as_deref(), Some("KEYID"));
2
    assert_eq!(merged.secrets_path, Some(vec!["task/path".to_string()]));
2
  }
  // ── VaultMeta serialization ───────────────────────────────────────────────
  #[test]
2
  fn test_vault_meta_toml_no_gpg_key_id() {
    // When gpg_key_id is None, the field is skipped in the TOML output
2
    let meta = VaultMeta {
2
      backend: None,
2
      keys_location: None,
2
      key_name: None,
2
      gpg_key_id: None,
2
    };
2
    let s = toml::to_string_pretty(&meta).unwrap();
2
    assert!(!s.contains("gpg_key_id"), "unexpected field in: {s}");
2
  }
  #[test]
2
  fn test_vault_meta_toml_with_gpg_key_id() {
2
    let meta = VaultMeta {
2
      backend: Some(SecretBackend::Gpg),
2
      keys_location: Some("./keys".to_string()),
2
      key_name: Some("vault".to_string()),
2
      gpg_key_id: Some("FINGERPRINT".to_string()),
2
    };
2
    let s = toml::to_string_pretty(&meta).unwrap();
2
    assert!(s.contains("backend"), "field missing from: {s}");
2
    assert!(s.contains("keys_location"), "field missing from: {s}");
2
    assert!(s.contains("key_name"), "field missing from: {s}");
2
    assert!(s.contains("gpg_key_id"), "field missing from: {s}");
2
    assert!(s.contains("FINGERPRINT"));
2
  }
}