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