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 serde::{
24 Deserialize,
25 Serialize,
26};
27
28use crate::file::ToUtf8 as _;
29use crate::utils::{
30 parse_env_contents,
31 resolve_path,
32};
33
34const VAULT_META_FILE: &str = ".vault-meta.toml";
35
36#[derive(Debug, Default, Deserialize, Serialize)]
40pub struct VaultMeta {
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub gpg_key_id: Option<String>,
44}
45
46pub fn read_vault_gpg_key_id(vault_location: &Path) -> Option<String> {
49 let content = fs::read_to_string(vault_location.join(VAULT_META_FILE)).ok()?;
50 let meta: VaultMeta = toml::from_str(&content).ok()?;
51 meta.gpg_key_id
52}
53
54pub fn write_vault_meta(vault_location: &Path, gpg_key_id: &str) -> anyhow::Result<()> {
56 let meta = VaultMeta {
57 gpg_key_id: Some(gpg_key_id.to_string()),
58 };
59 let content = toml::to_string_pretty(&meta).context("Failed to serialize vault metadata")?;
60 let meta_path = vault_location.join(VAULT_META_FILE);
61 let mut file = File::create(&meta_path)?;
62 file.write_all(content.as_bytes())?;
63 file.flush()?;
64 Ok(())
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct SecretConfig {
69 pub vault_location: PathBuf,
70 pub keys_location: PathBuf,
71 pub key_name: String,
72 pub gpg_key_id: Option<String>,
73}
74
75impl SecretConfig {
76 pub fn resolve(
77 base_dir: &Path,
78 vault_location: Option<&str>,
79 keys_location: Option<&str>,
80 key_name: Option<&str>,
81 gpg_key_id: Option<&str>,
82 ) -> Self {
83 let vault_location = vault_location
84 .map(|path| resolve_path(base_dir, path))
85 .unwrap_or_else(|| default_vault_location(base_dir));
86 let keys_location = keys_location
87 .map(|path| resolve_path(base_dir, path))
88 .unwrap_or_else(default_keys_location);
89 let key_name = key_name.unwrap_or("default").to_string();
90 let gpg_key_id = gpg_key_id
92 .map(|s| s.to_string())
93 .or_else(|| read_vault_gpg_key_id(&vault_location));
94
95 Self {
96 vault_location,
97 keys_location,
98 key_name,
99 gpg_key_id,
100 }
101 }
102}
103
104pub fn load_secret_values(
105 path: &str,
106 base_dir: &Path,
107 vault_location: Option<&str>,
108 keys_location: Option<&str>,
109 key_name: Option<&str>,
110 gpg_key_id: Option<&str>,
111) -> anyhow::Result<Vec<String>> {
112 let config = SecretConfig::resolve(base_dir, vault_location, keys_location, key_name, gpg_key_id);
113 verify_vault(&config.vault_location)?;
114
115 let secret_path = config.vault_location.join(path);
116 if !secret_path.exists() || !secret_path.is_dir() {
117 anyhow::bail!(
118 "Secret '{}' not found in vault. List available secrets with: mk secrets vault list",
119 path
120 );
121 }
122
123 let mut data_paths = fs::read_dir(&secret_path)?
124 .filter_map(Result::ok)
125 .map(|entry| {
126 if entry.path().is_dir() {
127 entry.path().join("data.asc")
128 } else {
129 entry.path()
130 }
131 })
132 .filter(|path| path.exists() && path.is_file())
133 .collect::<Vec<_>>();
134 data_paths.sort();
135
136 let use_gpg = config.gpg_key_id.is_some();
137 let signed_secret_key = if !use_gpg {
138 Some(load_secret_key(&config)?)
139 } else {
140 check_gpg_available()?;
141 None
142 };
143
144 let mut values = Vec::with_capacity(data_paths.len());
145 for data_path in data_paths {
146 let value = if use_gpg {
147 decrypt_with_gpg(&data_path)?
148 } else {
149 let key = signed_secret_key.as_ref().unwrap();
150 let mut data_file = std::io::BufReader::new(File::open(&data_path)?);
151 let (message, _) = Message::from_armor(&mut data_file)?;
152 let mut decrypted_message = message.decrypt(&pgp::types::Password::empty(), key)?;
153 decrypted_message
154 .as_data_string()
155 .context("Failed to read secret value")?
156 };
157 values.push(value);
158 }
159
160 if values.is_empty() {
161 anyhow::bail!(
162 "No secrets found for path '{}'. List available secrets with: mk secrets vault list",
163 path
164 );
165 }
166
167 Ok(values)
168}
169
170pub fn load_secret_value(
171 path: &str,
172 base_dir: &Path,
173 vault_location: Option<&str>,
174 keys_location: Option<&str>,
175 key_name: Option<&str>,
176 gpg_key_id: Option<&str>,
177) -> anyhow::Result<String> {
178 let values = load_secret_values(
179 path,
180 base_dir,
181 vault_location,
182 keys_location,
183 key_name,
184 gpg_key_id,
185 )?;
186 match values.as_slice() {
187 [value] => Ok(value.clone()),
188 [] => anyhow::bail!(
189 "No secrets found for path '{}'. List available secrets with: mk secrets vault list",
190 path
191 ),
192 _ => anyhow::bail!(
193 "Secret path '{}' resolved to multiple values; use a more specific identifier",
194 path
195 ),
196 }
197}
198
199pub fn list_secret_paths(
200 path_prefix: Option<&str>,
201 base_dir: &Path,
202 vault_location: Option<&str>,
203) -> anyhow::Result<Vec<String>> {
204 let config = SecretConfig::resolve(base_dir, vault_location, None, None, None);
205 verify_vault(&config.vault_location)?;
206
207 let root = match path_prefix {
208 Some(path_prefix) if !path_prefix.is_empty() => config.vault_location.join(path_prefix),
209 _ => config.vault_location.clone(),
210 };
211
212 if !root.exists() || !root.is_dir() {
213 anyhow::bail!(
214 "Secret prefix '{}' not found in vault. List available secrets with: mk secrets vault list",
215 path_prefix.unwrap_or("<unknown>")
216 );
217 }
218
219 let mut secret_paths = Vec::new();
220 collect_secret_paths(&config.vault_location, &root, &mut secret_paths)?;
221 secret_paths.sort();
222 secret_paths.dedup();
223 Ok(secret_paths)
224}
225
226pub fn load_secret_env(
227 paths: &[String],
228 base_dir: &Path,
229 vault_location: Option<&str>,
230 keys_location: Option<&str>,
231 key_name: Option<&str>,
232 gpg_key_id: Option<&str>,
233) -> anyhow::Result<HashMap<String, String>> {
234 let mut env_vars = HashMap::new();
235
236 for path in paths {
237 for value in load_secret_values(
238 path,
239 base_dir,
240 vault_location,
241 keys_location,
242 key_name,
243 gpg_key_id,
244 )? {
245 env_vars.extend(parse_env_contents(&value));
246 }
247 }
248
249 Ok(env_vars)
250}
251
252fn check_gpg_available() -> anyhow::Result<()> {
255 which::which("gpg")
256 .context("gpg is not available in PATH — install GnuPG to use hardware key (YubiKey) support")?;
257 Ok(())
258}
259
260fn default_vault_location(base_dir: &Path) -> PathBuf {
261 resolve_path(base_dir, "./.mk/vault")
262}
263
264pub fn encrypt_with_gpg(gpg_key_id: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
267 check_gpg_available()?;
268 let mut child = Command::new("gpg")
269 .args([
270 "--batch",
271 "--yes",
272 "--armor",
273 "--encrypt",
274 "--recipient",
275 gpg_key_id,
276 ])
277 .stdin(Stdio::piped())
278 .stdout(Stdio::piped())
279 .stderr(Stdio::piped())
280 .spawn()
281 .context("Failed to spawn gpg — is it installed and in PATH?")?;
282
283 if let Some(mut stdin) = child.stdin.take() {
284 stdin
285 .write_all(plaintext)
286 .context("Failed to write plaintext to gpg stdin")?;
287 }
288
289 let output = child
290 .wait_with_output()
291 .context("Failed to wait for gpg encrypt")?;
292 if !output.status.success() {
293 let stderr = String::from_utf8_lossy(&output.stderr);
294 anyhow::bail!("gpg encryption failed: {}", stderr.trim());
295 }
296 Ok(output.stdout)
297}
298
299fn decrypt_with_gpg(data_path: &Path) -> anyhow::Result<String> {
302 let path_str = data_path
303 .to_str()
304 .ok_or_else(|| anyhow::anyhow!("Non-UTF-8 path: {:?}", data_path))?;
305
306 let output = Command::new("gpg")
307 .args(["--batch", "--decrypt", path_str])
308 .stdout(Stdio::piped())
309 .stderr(Stdio::piped())
310 .spawn()
311 .context("Failed to spawn gpg — is it installed and in PATH?")?
312 .wait_with_output()
313 .context("Failed to wait for gpg decrypt")?;
314
315 if !output.status.success() {
316 let stderr = String::from_utf8_lossy(&output.stderr);
317 anyhow::bail!("gpg decryption failed: {}", stderr.trim());
318 }
319 String::from_utf8(output.stdout).context("gpg decrypt output is not valid UTF-8")
320}
321
322fn default_keys_location() -> PathBuf {
323 let home_dir = if cfg!(target_os = "windows") {
324 env::var("USERPROFILE").unwrap_or_else(|_| "./.mk/priv".to_string())
325 } else {
326 env::var("HOME").unwrap_or_else(|_| "./.mk/priv".to_string())
327 };
328
329 let mut path = PathBuf::from(home_dir);
330 path.push(".config");
331 path.push("mk");
332 path.push("priv");
333 path
334}
335
336fn verify_vault(vault_location: &Path) -> anyhow::Result<()> {
337 if !vault_location.exists() || !vault_location.is_dir() {
338 anyhow::bail!(
339 "Vault not found at '{}'. Initialize it first with: mk secrets vault init",
340 vault_location.to_utf8().unwrap_or("<non-utf8-path>")
341 );
342 }
343
344 Ok(())
345}
346
347fn load_secret_key(config: &SecretConfig) -> anyhow::Result<SignedSecretKey> {
348 if !config.keys_location.exists() || !config.keys_location.is_dir() {
349 anyhow::bail!(
350 "Keys directory not found at '{}'. Generate a key first with: mk secrets key gen",
351 config.keys_location.to_utf8().unwrap_or("<non-utf8-path>")
352 );
353 }
354
355 let key_path = config.keys_location.join(format!("{}.key", config.key_name));
356 if !key_path.exists() || !key_path.is_file() {
357 anyhow::bail!(
358 "Key '{}' not found in '{}'. Generate it with: mk secrets key gen --name {}",
359 config.key_name,
360 config.keys_location.to_utf8().unwrap_or("<non-utf8-path>"),
361 config.key_name
362 );
363 }
364
365 let mut secret_key_string = File::open(key_path)?;
366 let (signed_secret_key, _) = SignedSecretKey::from_armor_single(&mut secret_key_string)?;
367 signed_secret_key.verify_bindings()?;
368 Ok(signed_secret_key)
369}
370
371fn collect_secret_paths(vault_root: &Path, dir: &Path, secret_paths: &mut Vec<String>) -> anyhow::Result<()> {
372 let data_path = dir.join("data.asc");
373 if data_path.exists() && data_path.is_file() {
374 let relative = dir.strip_prefix(vault_root).map_err(|_| {
375 let dir = dir.to_utf8().unwrap_or("<non-utf8-path>");
376 let vault_root = vault_root.to_utf8().unwrap_or("<non-utf8-path>");
377 anyhow::anyhow!(
378 "Failed to resolve secret path '{}' relative to vault root '{}'",
379 dir,
380 vault_root
381 )
382 })?;
383 secret_paths.push(relative.to_utf8().unwrap_or("<non-utf8-path>").to_string());
384 }
385
386 for entry in fs::read_dir(dir)?.filter_map(Result::ok) {
387 let path = entry.path();
388 if path.is_dir() {
389 collect_secret_paths(vault_root, &path, secret_paths)?;
390 }
391 }
392
393 Ok(())
394}
395
396#[cfg(test)]
397mod tests {
398 use std::fs;
399
400 use assert_fs::TempDir;
401
402 use super::*;
403
404 #[test]
407 fn test_vault_meta_roundtrip() {
408 let dir = TempDir::new().unwrap();
409 let vault_dir = dir.path();
410
411 assert_eq!(read_vault_gpg_key_id(vault_dir), None);
413
414 write_vault_meta(vault_dir, "ABC123DEF456").unwrap();
416
417 assert_eq!(read_vault_gpg_key_id(vault_dir), Some("ABC123DEF456".to_string()));
419 }
420
421 #[test]
422 fn test_vault_meta_overwrite() {
423 let dir = TempDir::new().unwrap();
424 let vault_dir = dir.path();
425
426 write_vault_meta(vault_dir, "FIRST_KEY").unwrap();
427 write_vault_meta(vault_dir, "SECOND_KEY").unwrap();
428
429 assert_eq!(read_vault_gpg_key_id(vault_dir), Some("SECOND_KEY".to_string()));
430 }
431
432 #[test]
433 fn test_read_vault_gpg_key_id_missing_file() {
434 let dir = TempDir::new().unwrap();
435 assert_eq!(read_vault_gpg_key_id(dir.path()), None);
436 }
437
438 #[test]
439 fn test_read_vault_gpg_key_id_invalid_toml() {
440 let dir = TempDir::new().unwrap();
441 fs::write(dir.path().join(VAULT_META_FILE), b"not_valid [ toml {{").unwrap();
442 assert_eq!(read_vault_gpg_key_id(dir.path()), None);
444 }
445
446 #[test]
449 fn test_secret_config_explicit_gpg_key_id() {
450 let dir = TempDir::new().unwrap();
451 let vault_dir = dir.path().to_str().unwrap();
452 let base = Path::new(".");
453 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, Some("EXPLICIT_ID"));
454 assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
455 }
456
457 #[test]
458 fn test_secret_config_gpg_key_id_from_vault_metadata() {
459 let dir = TempDir::new().unwrap();
460 let vault_dir = dir.path().to_str().unwrap();
461 write_vault_meta(dir.path(), "META_ID").unwrap();
462
463 let base = Path::new(".");
464 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
465 assert_eq!(config.gpg_key_id, Some("META_ID".to_string()));
466 }
467
468 #[test]
469 fn test_secret_config_explicit_gpg_key_id_overrides_metadata() {
470 let dir = TempDir::new().unwrap();
471 let vault_dir = dir.path().to_str().unwrap();
472 write_vault_meta(dir.path(), "META_ID").unwrap();
473
474 let base = Path::new(".");
475 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, Some("EXPLICIT_ID"));
476 assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
478 }
479
480 #[test]
481 fn test_secret_config_no_gpg_key_id() {
482 let dir = TempDir::new().unwrap();
483 let vault_dir = dir.path().to_str().unwrap();
484 let base = Path::new(".");
485 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
487 assert_eq!(config.gpg_key_id, None);
488 }
489
490 #[test]
491 fn test_secret_config_key_name_default() {
492 let dir = TempDir::new().unwrap();
493 let vault_dir = dir.path().to_str().unwrap();
494 let base = Path::new(".");
495 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
496 assert_eq!(config.key_name, "default");
497 }
498
499 #[test]
500 fn test_secret_config_key_name_custom() {
501 let dir = TempDir::new().unwrap();
502 let vault_dir = dir.path().to_str().unwrap();
503 let base = Path::new(".");
504 let config = SecretConfig::resolve(base, Some(vault_dir), None, Some("mykey"), None);
505 assert_eq!(config.key_name, "mykey");
506 }
507
508 #[test]
511 fn test_vault_meta_toml_no_gpg_key_id() {
512 let meta = VaultMeta { gpg_key_id: None };
514 let s = toml::to_string_pretty(&meta).unwrap();
515 assert!(!s.contains("gpg_key_id"), "unexpected field in: {s}");
516 }
517
518 #[test]
519 fn test_vault_meta_toml_with_gpg_key_id() {
520 let meta = VaultMeta {
521 gpg_key_id: Some("FINGERPRINT".to_string()),
522 };
523 let s = toml::to_string_pretty(&meta).unwrap();
524 assert!(s.contains("gpg_key_id"), "field missing from: {s}");
525 assert!(s.contains("FINGERPRINT"));
526 }
527}