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 path does not exist: {}",
119 secret_path.to_utf8().unwrap_or("<non-utf8-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!("No secrets found for path: {path}");
162 }
163
164 Ok(values)
165}
166
167pub fn load_secret_value(
168 path: &str,
169 base_dir: &Path,
170 vault_location: Option<&str>,
171 keys_location: Option<&str>,
172 key_name: Option<&str>,
173 gpg_key_id: Option<&str>,
174) -> anyhow::Result<String> {
175 let values = load_secret_values(
176 path,
177 base_dir,
178 vault_location,
179 keys_location,
180 key_name,
181 gpg_key_id,
182 )?;
183 match values.as_slice() {
184 [value] => Ok(value.clone()),
185 [] => anyhow::bail!("No secrets found for path: {path}"),
186 _ => anyhow::bail!("Secret path resolved to multiple values: {path}"),
187 }
188}
189
190pub fn list_secret_paths(
191 path_prefix: Option<&str>,
192 base_dir: &Path,
193 vault_location: Option<&str>,
194) -> anyhow::Result<Vec<String>> {
195 let config = SecretConfig::resolve(base_dir, vault_location, None, None, None);
196 verify_vault(&config.vault_location)?;
197
198 let root = match path_prefix {
199 Some(path_prefix) if !path_prefix.is_empty() => config.vault_location.join(path_prefix),
200 _ => config.vault_location.clone(),
201 };
202
203 if !root.exists() || !root.is_dir() {
204 anyhow::bail!(
205 "Secret path does not exist: {}",
206 root.to_utf8().unwrap_or("<non-utf8-path>")
207 );
208 }
209
210 let mut secret_paths = Vec::new();
211 collect_secret_paths(&config.vault_location, &root, &mut secret_paths)?;
212 secret_paths.sort();
213 secret_paths.dedup();
214 Ok(secret_paths)
215}
216
217pub fn load_secret_env(
218 paths: &[String],
219 base_dir: &Path,
220 vault_location: Option<&str>,
221 keys_location: Option<&str>,
222 key_name: Option<&str>,
223 gpg_key_id: Option<&str>,
224) -> anyhow::Result<HashMap<String, String>> {
225 let mut env_vars = HashMap::new();
226
227 for path in paths {
228 for value in load_secret_values(
229 path,
230 base_dir,
231 vault_location,
232 keys_location,
233 key_name,
234 gpg_key_id,
235 )? {
236 env_vars.extend(parse_env_contents(&value));
237 }
238 }
239
240 Ok(env_vars)
241}
242
243fn check_gpg_available() -> anyhow::Result<()> {
246 which::which("gpg")
247 .context("gpg is not available in PATH — install GnuPG to use hardware key (YubiKey) support")?;
248 Ok(())
249}
250
251fn default_vault_location(base_dir: &Path) -> PathBuf {
252 resolve_path(base_dir, "./.mk/vault")
253}
254
255pub fn encrypt_with_gpg(gpg_key_id: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
258 check_gpg_available()?;
259 let mut child = Command::new("gpg")
260 .args([
261 "--batch",
262 "--yes",
263 "--armor",
264 "--encrypt",
265 "--recipient",
266 gpg_key_id,
267 ])
268 .stdin(Stdio::piped())
269 .stdout(Stdio::piped())
270 .stderr(Stdio::piped())
271 .spawn()
272 .context("Failed to spawn gpg — is it installed and in PATH?")?;
273
274 if let Some(mut stdin) = child.stdin.take() {
275 stdin
276 .write_all(plaintext)
277 .context("Failed to write plaintext to gpg stdin")?;
278 }
279
280 let output = child
281 .wait_with_output()
282 .context("Failed to wait for gpg encrypt")?;
283 if !output.status.success() {
284 let stderr = String::from_utf8_lossy(&output.stderr);
285 anyhow::bail!("gpg encryption failed: {}", stderr.trim());
286 }
287 Ok(output.stdout)
288}
289
290fn decrypt_with_gpg(data_path: &Path) -> anyhow::Result<String> {
293 let path_str = data_path
294 .to_str()
295 .ok_or_else(|| anyhow::anyhow!("Non-UTF-8 path: {:?}", data_path))?;
296
297 let output = Command::new("gpg")
298 .args(["--batch", "--decrypt", path_str])
299 .stdout(Stdio::piped())
300 .stderr(Stdio::piped())
301 .spawn()
302 .context("Failed to spawn gpg — is it installed and in PATH?")?
303 .wait_with_output()
304 .context("Failed to wait for gpg decrypt")?;
305
306 if !output.status.success() {
307 let stderr = String::from_utf8_lossy(&output.stderr);
308 anyhow::bail!("gpg decryption failed: {}", stderr.trim());
309 }
310 String::from_utf8(output.stdout).context("gpg decrypt output is not valid UTF-8")
311}
312
313fn default_keys_location() -> PathBuf {
314 let home_dir = if cfg!(target_os = "windows") {
315 env::var("USERPROFILE").unwrap_or_else(|_| "./.mk/priv".to_string())
316 } else {
317 env::var("HOME").unwrap_or_else(|_| "./.mk/priv".to_string())
318 };
319
320 let mut path = PathBuf::from(home_dir);
321 path.push(".config");
322 path.push("mk");
323 path.push("priv");
324 path
325}
326
327fn verify_vault(vault_location: &Path) -> anyhow::Result<()> {
328 if !vault_location.exists() || !vault_location.is_dir() {
329 anyhow::bail!("The store does not exist");
330 }
331
332 Ok(())
333}
334
335fn load_secret_key(config: &SecretConfig) -> anyhow::Result<SignedSecretKey> {
336 if !config.keys_location.exists() || !config.keys_location.is_dir() {
337 anyhow::bail!("The keys location does not exist");
338 }
339
340 let key_path = config.keys_location.join(format!("{}.key", config.key_name));
341 if !key_path.exists() || !key_path.is_file() {
342 anyhow::bail!("The key does not exist");
343 }
344
345 let mut secret_key_string = File::open(key_path)?;
346 let (signed_secret_key, _) = SignedSecretKey::from_armor_single(&mut secret_key_string)?;
347 signed_secret_key.verify()?;
348 Ok(signed_secret_key)
349}
350
351fn collect_secret_paths(vault_root: &Path, dir: &Path, secret_paths: &mut Vec<String>) -> anyhow::Result<()> {
352 let data_path = dir.join("data.asc");
353 if data_path.exists() && data_path.is_file() {
354 let relative = dir
355 .strip_prefix(vault_root)
356 .map_err(|_| anyhow::anyhow!("Failed to resolve secret path relative to vault"))?;
357 secret_paths.push(relative.to_utf8().unwrap_or("<non-utf8-path>").to_string());
358 }
359
360 for entry in fs::read_dir(dir)?.filter_map(Result::ok) {
361 let path = entry.path();
362 if path.is_dir() {
363 collect_secret_paths(vault_root, &path, secret_paths)?;
364 }
365 }
366
367 Ok(())
368}
369
370#[cfg(test)]
371mod tests {
372 use std::fs;
373
374 use assert_fs::TempDir;
375
376 use super::*;
377
378 #[test]
381 fn test_vault_meta_roundtrip() {
382 let dir = TempDir::new().unwrap();
383 let vault_dir = dir.path();
384
385 assert_eq!(read_vault_gpg_key_id(vault_dir), None);
387
388 write_vault_meta(vault_dir, "ABC123DEF456").unwrap();
390
391 assert_eq!(read_vault_gpg_key_id(vault_dir), Some("ABC123DEF456".to_string()));
393 }
394
395 #[test]
396 fn test_vault_meta_overwrite() {
397 let dir = TempDir::new().unwrap();
398 let vault_dir = dir.path();
399
400 write_vault_meta(vault_dir, "FIRST_KEY").unwrap();
401 write_vault_meta(vault_dir, "SECOND_KEY").unwrap();
402
403 assert_eq!(read_vault_gpg_key_id(vault_dir), Some("SECOND_KEY".to_string()));
404 }
405
406 #[test]
407 fn test_read_vault_gpg_key_id_missing_file() {
408 let dir = TempDir::new().unwrap();
409 assert_eq!(read_vault_gpg_key_id(dir.path()), None);
410 }
411
412 #[test]
413 fn test_read_vault_gpg_key_id_invalid_toml() {
414 let dir = TempDir::new().unwrap();
415 fs::write(dir.path().join(VAULT_META_FILE), b"not_valid [ toml {{").unwrap();
416 assert_eq!(read_vault_gpg_key_id(dir.path()), None);
418 }
419
420 #[test]
423 fn test_secret_config_explicit_gpg_key_id() {
424 let dir = TempDir::new().unwrap();
425 let vault_dir = dir.path().to_str().unwrap();
426 let base = Path::new(".");
427 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, Some("EXPLICIT_ID"));
428 assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
429 }
430
431 #[test]
432 fn test_secret_config_gpg_key_id_from_vault_metadata() {
433 let dir = TempDir::new().unwrap();
434 let vault_dir = dir.path().to_str().unwrap();
435 write_vault_meta(dir.path(), "META_ID").unwrap();
436
437 let base = Path::new(".");
438 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
439 assert_eq!(config.gpg_key_id, Some("META_ID".to_string()));
440 }
441
442 #[test]
443 fn test_secret_config_explicit_gpg_key_id_overrides_metadata() {
444 let dir = TempDir::new().unwrap();
445 let vault_dir = dir.path().to_str().unwrap();
446 write_vault_meta(dir.path(), "META_ID").unwrap();
447
448 let base = Path::new(".");
449 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, Some("EXPLICIT_ID"));
450 assert_eq!(config.gpg_key_id, Some("EXPLICIT_ID".to_string()));
452 }
453
454 #[test]
455 fn test_secret_config_no_gpg_key_id() {
456 let dir = TempDir::new().unwrap();
457 let vault_dir = dir.path().to_str().unwrap();
458 let base = Path::new(".");
459 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
461 assert_eq!(config.gpg_key_id, None);
462 }
463
464 #[test]
465 fn test_secret_config_key_name_default() {
466 let dir = TempDir::new().unwrap();
467 let vault_dir = dir.path().to_str().unwrap();
468 let base = Path::new(".");
469 let config = SecretConfig::resolve(base, Some(vault_dir), None, None, None);
470 assert_eq!(config.key_name, "default");
471 }
472
473 #[test]
474 fn test_secret_config_key_name_custom() {
475 let dir = TempDir::new().unwrap();
476 let vault_dir = dir.path().to_str().unwrap();
477 let base = Path::new(".");
478 let config = SecretConfig::resolve(base, Some(vault_dir), None, Some("mykey"), None);
479 assert_eq!(config.key_name, "mykey");
480 }
481
482 #[test]
485 fn test_vault_meta_toml_no_gpg_key_id() {
486 let meta = VaultMeta { gpg_key_id: None };
488 let s = toml::to_string_pretty(&meta).unwrap();
489 assert!(!s.contains("gpg_key_id"), "unexpected field in: {s}");
490 }
491
492 #[test]
493 fn test_vault_meta_toml_with_gpg_key_id() {
494 let meta = VaultMeta {
495 gpg_key_id: Some("FINGERPRINT".to_string()),
496 };
497 let s = toml::to_string_pretty(&meta).unwrap();
498 assert!(s.contains("gpg_key_id"), "field missing from: {s}");
499 assert!(s.contains("FINGERPRINT"));
500 }
501}