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