1use std::collections::{BTreeMap, HashMap};
4use std::process::Command;
5#[cfg(test)]
8use std::env;
9
10use crate::{crypto, encrypt_value, now_utc, types};
11use age::secrecy::{ExposeSecret, SecretString};
12
13fn sanitize_remote_url(url: &str) -> String {
19 if let Some(rest) = url
20 .strip_prefix("https://")
21 .or_else(|| url.strip_prefix("http://"))
22 {
23 let scheme = if url.starts_with("https://") {
24 "https"
25 } else {
26 "http"
27 };
28 if let Some(at_pos) = rest.find('@') {
29 let slash_pos = rest.find('/').unwrap_or(rest.len());
31 if at_pos < slash_pos {
32 return format!("{scheme}://{}", &rest[at_pos + 1..]);
33 }
34 }
35 url.to_string()
36 } else {
37 url.to_string()
38 }
39}
40
41#[derive(Debug)]
43pub struct DiscoveredKey {
44 pub secret_key: SecretString,
45 pub pubkey: String,
46}
47
48pub fn discover_existing_key() -> Result<Option<DiscoveredKey>, String> {
55 let raw = crate::env::key_from_env_only()?;
58
59 match raw {
60 Some(key) => {
61 let identity =
62 crypto::parse_identity(key.expose_secret()).map_err(|e| e.to_string())?;
63 let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
64 Ok(Some(DiscoveredKey {
65 secret_key: key,
66 pubkey,
67 }))
68 }
69 None => Ok(None),
70 }
71}
72
73#[derive(Debug)]
75pub struct InitStatus {
76 pub authorized: bool,
78 pub pubkey: String,
80 pub display_name: Option<String>,
82}
83
84pub fn check_init_status(vault: &types::Vault, secret_key: &str) -> Result<InitStatus, String> {
89 let identity = crypto::parse_identity(secret_key).map_err(|e| e.to_string())?;
90 let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
91 let authorized = vault.recipients.contains(&pubkey);
92
93 let display_name = if authorized {
94 crate::decrypt_meta(vault, &identity)
95 .and_then(|meta| meta.recipients.get(&pubkey).cloned())
96 .filter(|name| !name.is_empty())
97 } else {
98 None
99 };
100
101 Ok(InitStatus {
102 authorized,
103 pubkey,
104 display_name,
105 })
106}
107
108pub fn create_vault(
113 vault_name: &str,
114 pubkey: &str,
115 name: &str,
116) -> Result<types::Vault, crate::error::MurkError> {
117 use crate::error::MurkError;
118
119 let mut recipient_names = HashMap::new();
120 recipient_names.insert(pubkey.to_string(), name.to_string());
121
122 let recipient = crypto::parse_recipient(pubkey)?;
123
124 let repo = Command::new("git")
126 .args(["remote", "get-url", "origin"])
127 .output()
128 .ok()
129 .filter(|o| o.status.success())
130 .and_then(|o| String::from_utf8(o.stdout).ok())
131 .map(|s| sanitize_remote_url(s.trim()))
132 .unwrap_or_default();
133
134 let mut vault = types::Vault {
135 version: types::VAULT_VERSION.into(),
136 created: now_utc(),
137 vault_name: vault_name.into(),
138 repo,
139 recipients: vec![pubkey.to_string()],
140 schema: BTreeMap::new(),
141 policy: None,
142 secrets: BTreeMap::new(),
143 meta: String::new(),
144 };
145
146 let mac_key_hex = crate::generate_mac_key();
147 let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
148 let mac = crate::compute_mac(&vault, &BTreeMap::new(), &BTreeMap::new(), Some(&mac_key));
149 let meta = types::Meta {
152 recipients: recipient_names,
153 mac,
154 mac_key: Some(mac_key_hex),
155 github_pins: HashMap::new(),
156 groups: BTreeMap::new(),
157 grants: BTreeMap::new(),
158 signers: BTreeMap::new(),
159 sig: None,
160 };
161 let meta_json =
162 serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
163 vault.meta = encrypt_value(&meta_json, &[recipient])?;
164
165 Ok(vault)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::testutil::*;
172 use crate::testutil::{CWD_LOCK, ENV_LOCK};
173
174 #[test]
177 fn discover_existing_key_from_env() {
178 let _lock = ENV_LOCK
179 .lock()
180 .unwrap_or_else(std::sync::PoisonError::into_inner);
181 let (secret, pubkey) = generate_keypair();
182 unsafe { env::set_var("MURK_KEY", &secret) };
183 let result = discover_existing_key();
184 unsafe { env::remove_var("MURK_KEY") };
185
186 let dk = result.unwrap().unwrap();
187 assert_eq!(dk.secret_key.expose_secret(), secret.as_str());
188 assert_eq!(dk.pubkey, pubkey);
189 }
190
191 #[test]
192 fn discover_existing_key_ignores_dotenv() {
193 let _lock = ENV_LOCK
197 .lock()
198 .unwrap_or_else(std::sync::PoisonError::into_inner);
199 let _cwd = CWD_LOCK
200 .lock()
201 .unwrap_or_else(std::sync::PoisonError::into_inner);
202 unsafe {
203 env::remove_var("MURK_KEY");
204 env::remove_var("MURK_KEY_FILE");
205 }
206
207 let dir = std::env::temp_dir().join("murk_test_discover_ignores_dotenv");
208 std::fs::create_dir_all(&dir).unwrap();
209 let (secret, _pubkey) = generate_keypair();
210 std::fs::write(dir.join(".env"), format!("MURK_KEY={secret}\n")).unwrap();
211
212 let orig_dir = std::env::current_dir().unwrap();
213 std::env::set_current_dir(&dir).unwrap();
214 let result = discover_existing_key();
215 std::env::set_current_dir(&orig_dir).unwrap();
216 std::fs::remove_dir_all(&dir).unwrap();
217
218 assert!(
219 result.unwrap().is_none(),
220 "discover_existing_key must not fall back to .env"
221 );
222 }
223
224 #[test]
225 fn discover_existing_key_from_env_file_var() {
226 let _lock = ENV_LOCK
227 .lock()
228 .unwrap_or_else(std::sync::PoisonError::into_inner);
229 unsafe {
230 env::remove_var("MURK_KEY");
231 }
232
233 let (secret, pubkey) = generate_keypair();
234 let dir = std::env::temp_dir().join("murk_test_discover_env_file");
235 std::fs::create_dir_all(&dir).unwrap();
236 let key_path = dir.join("key");
237 std::fs::write(&key_path, format!("{secret}\n")).unwrap();
238 #[cfg(unix)]
239 {
240 use std::os::unix::fs::PermissionsExt;
241 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
242 }
243
244 unsafe { env::set_var("MURK_KEY_FILE", &key_path) };
245 let result = discover_existing_key();
246 unsafe { env::remove_var("MURK_KEY_FILE") };
247 std::fs::remove_dir_all(&dir).unwrap();
248
249 let dk = result.unwrap().unwrap();
250 assert_eq!(dk.secret_key.expose_secret(), secret.as_str());
251 assert_eq!(dk.pubkey, pubkey);
252 }
253
254 #[test]
255 fn discover_existing_key_neither_set() {
256 let _lock = ENV_LOCK
257 .lock()
258 .unwrap_or_else(std::sync::PoisonError::into_inner);
259 let _cwd = CWD_LOCK
260 .lock()
261 .unwrap_or_else(std::sync::PoisonError::into_inner);
262 unsafe { env::remove_var("MURK_KEY") };
263
264 let dir = std::env::temp_dir().join("murk_test_discover_none");
266 std::fs::create_dir_all(&dir).unwrap();
267 let orig_dir = std::env::current_dir().unwrap();
268 std::env::set_current_dir(&dir).unwrap();
269 let result = discover_existing_key();
270 std::env::set_current_dir(&orig_dir).unwrap();
271 std::fs::remove_dir_all(&dir).unwrap();
272
273 assert!(result.unwrap().is_none());
274 }
275
276 #[test]
277 fn discover_existing_key_invalid_key() {
278 let _lock = ENV_LOCK
279 .lock()
280 .unwrap_or_else(std::sync::PoisonError::into_inner);
281 unsafe { env::set_var("MURK_KEY", "not-a-valid-age-key") };
282 let result = discover_existing_key();
283 unsafe { env::remove_var("MURK_KEY") };
284
285 assert!(result.is_err());
286 }
287
288 #[test]
291 fn check_init_status_authorized() {
292 let (secret, pubkey) = generate_keypair();
293 let recipient = make_recipient(&pubkey);
294
295 let mut names = HashMap::new();
297 names.insert(pubkey.clone(), "Alice".to_string());
298 let meta = types::Meta {
299 recipients: names,
300 mac: String::new(),
301 mac_key: None,
302 github_pins: HashMap::new(),
303 ..Default::default()
304 };
305 let meta_json = serde_json::to_vec(&meta).unwrap();
306 let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap();
307
308 let vault = types::Vault {
309 version: "2.0".into(),
310 created: "2026-01-01T00:00:00Z".into(),
311 vault_name: ".murk".into(),
312 repo: String::new(),
313 recipients: vec![pubkey.clone()],
314 schema: std::collections::BTreeMap::new(),
315 policy: None,
316 secrets: std::collections::BTreeMap::new(),
317 meta: meta_enc,
318 };
319
320 let status = check_init_status(&vault, &secret).unwrap();
321 assert!(status.authorized);
322 assert_eq!(status.pubkey, pubkey);
323 assert_eq!(status.display_name.as_deref(), Some("Alice"));
324 }
325
326 #[test]
327 fn check_init_status_not_authorized() {
328 let (secret, pubkey) = generate_keypair();
329 let (_, other_pubkey) = generate_keypair();
330
331 let vault = types::Vault {
332 version: "2.0".into(),
333 created: "2026-01-01T00:00:00Z".into(),
334 vault_name: ".murk".into(),
335 repo: String::new(),
336 recipients: vec![other_pubkey],
337 schema: std::collections::BTreeMap::new(),
338 policy: None,
339 secrets: std::collections::BTreeMap::new(),
340 meta: String::new(),
341 };
342
343 let status = check_init_status(&vault, &secret).unwrap();
344 assert!(!status.authorized);
345 assert_eq!(status.pubkey, pubkey);
346 assert!(status.display_name.is_none());
347 }
348
349 #[test]
350 fn create_vault_basic() {
351 let (_, pubkey) = generate_keypair();
352
353 let vault = create_vault(".murk", &pubkey, "Bob").unwrap();
354 assert_eq!(vault.version, types::VAULT_VERSION);
355 assert_eq!(vault.vault_name, ".murk");
356 assert_eq!(vault.recipients, vec![pubkey]);
357 assert!(vault.schema.is_empty());
358 assert!(vault.secrets.is_empty());
359 assert!(!vault.meta.is_empty());
360 }
361
362 #[test]
365 fn sanitize_strips_https_credentials() {
366 assert_eq!(
367 sanitize_remote_url("https://user:pass@github.com/org/repo.git"),
368 "https://github.com/org/repo.git"
369 );
370 }
371
372 #[test]
373 fn sanitize_strips_https_token() {
374 assert_eq!(
375 sanitize_remote_url("https://ghp_abc123@github.com/org/repo.git"),
376 "https://github.com/org/repo.git"
377 );
378 }
379
380 #[test]
381 fn sanitize_preserves_clean_https() {
382 assert_eq!(
383 sanitize_remote_url("https://github.com/org/repo.git"),
384 "https://github.com/org/repo.git"
385 );
386 }
387
388 #[test]
389 fn sanitize_preserves_ssh() {
390 assert_eq!(
391 sanitize_remote_url("git@github.com:org/repo.git"),
392 "git@github.com:org/repo.git"
393 );
394 }
395
396 #[test]
397 fn sanitize_strips_http_credentials() {
398 assert_eq!(
399 sanitize_remote_url("http://user:pass@example.com/repo"),
400 "http://example.com/repo"
401 );
402 }
403}