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 {
150 recipients: recipient_names,
151 mac,
152 mac_key: Some(mac_key_hex),
153 github_pins: HashMap::new(),
154 groups: BTreeMap::new(),
155 grants: BTreeMap::new(),
156 signers: BTreeMap::new(),
157 sig: None,
158 };
159 let meta_json =
160 serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
161 vault.meta = encrypt_value(&meta_json, &[recipient])?;
162
163 Ok(vault)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::testutil::*;
170 use crate::testutil::{CWD_LOCK, ENV_LOCK};
171
172 #[test]
175 fn discover_existing_key_from_env() {
176 let _lock = ENV_LOCK
177 .lock()
178 .unwrap_or_else(std::sync::PoisonError::into_inner);
179 let (secret, pubkey) = generate_keypair();
180 unsafe { env::set_var("MURK_KEY", &secret) };
181 let result = discover_existing_key();
182 unsafe { env::remove_var("MURK_KEY") };
183
184 let dk = result.unwrap().unwrap();
185 assert_eq!(dk.secret_key, secret);
186 assert_eq!(dk.pubkey, pubkey);
187 }
188
189 #[test]
190 fn discover_existing_key_ignores_dotenv() {
191 let _lock = ENV_LOCK
195 .lock()
196 .unwrap_or_else(std::sync::PoisonError::into_inner);
197 let _cwd = CWD_LOCK
198 .lock()
199 .unwrap_or_else(std::sync::PoisonError::into_inner);
200 unsafe {
201 env::remove_var("MURK_KEY");
202 env::remove_var("MURK_KEY_FILE");
203 }
204
205 let dir = std::env::temp_dir().join("murk_test_discover_ignores_dotenv");
206 std::fs::create_dir_all(&dir).unwrap();
207 let (secret, _pubkey) = generate_keypair();
208 std::fs::write(dir.join(".env"), format!("MURK_KEY={secret}\n")).unwrap();
209
210 let orig_dir = std::env::current_dir().unwrap();
211 std::env::set_current_dir(&dir).unwrap();
212 let result = discover_existing_key();
213 std::env::set_current_dir(&orig_dir).unwrap();
214 std::fs::remove_dir_all(&dir).unwrap();
215
216 assert!(
217 result.unwrap().is_none(),
218 "discover_existing_key must not fall back to .env"
219 );
220 }
221
222 #[test]
223 fn discover_existing_key_from_env_file_var() {
224 let _lock = ENV_LOCK
225 .lock()
226 .unwrap_or_else(std::sync::PoisonError::into_inner);
227 unsafe {
228 env::remove_var("MURK_KEY");
229 }
230
231 let (secret, pubkey) = generate_keypair();
232 let dir = std::env::temp_dir().join("murk_test_discover_env_file");
233 std::fs::create_dir_all(&dir).unwrap();
234 let key_path = dir.join("key");
235 std::fs::write(&key_path, format!("{secret}\n")).unwrap();
236 #[cfg(unix)]
237 {
238 use std::os::unix::fs::PermissionsExt;
239 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
240 }
241
242 unsafe { env::set_var("MURK_KEY_FILE", &key_path) };
243 let result = discover_existing_key();
244 unsafe { env::remove_var("MURK_KEY_FILE") };
245 std::fs::remove_dir_all(&dir).unwrap();
246
247 let dk = result.unwrap().unwrap();
248 assert_eq!(dk.secret_key, secret);
249 assert_eq!(dk.pubkey, pubkey);
250 }
251
252 #[test]
253 fn discover_existing_key_neither_set() {
254 let _lock = ENV_LOCK
255 .lock()
256 .unwrap_or_else(std::sync::PoisonError::into_inner);
257 let _cwd = CWD_LOCK
258 .lock()
259 .unwrap_or_else(std::sync::PoisonError::into_inner);
260 unsafe { env::remove_var("MURK_KEY") };
261
262 let dir = std::env::temp_dir().join("murk_test_discover_none");
264 std::fs::create_dir_all(&dir).unwrap();
265 let orig_dir = std::env::current_dir().unwrap();
266 std::env::set_current_dir(&dir).unwrap();
267 let result = discover_existing_key();
268 std::env::set_current_dir(&orig_dir).unwrap();
269 std::fs::remove_dir_all(&dir).unwrap();
270
271 assert!(result.unwrap().is_none());
272 }
273
274 #[test]
275 fn discover_existing_key_invalid_key() {
276 let _lock = ENV_LOCK
277 .lock()
278 .unwrap_or_else(std::sync::PoisonError::into_inner);
279 unsafe { env::set_var("MURK_KEY", "not-a-valid-age-key") };
280 let result = discover_existing_key();
281 unsafe { env::remove_var("MURK_KEY") };
282
283 assert!(result.is_err());
284 }
285
286 #[test]
289 fn check_init_status_authorized() {
290 let (secret, pubkey) = generate_keypair();
291 let recipient = make_recipient(&pubkey);
292
293 let mut names = HashMap::new();
295 names.insert(pubkey.clone(), "Alice".to_string());
296 let meta = types::Meta {
297 recipients: names,
298 mac: String::new(),
299 mac_key: None,
300 github_pins: HashMap::new(),
301 ..Default::default()
302 };
303 let meta_json = serde_json::to_vec(&meta).unwrap();
304 let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap();
305
306 let vault = types::Vault {
307 version: "2.0".into(),
308 created: "2026-01-01T00:00:00Z".into(),
309 vault_name: ".murk".into(),
310 repo: String::new(),
311 recipients: vec![pubkey.clone()],
312 schema: std::collections::BTreeMap::new(),
313 policy: None,
314 secrets: std::collections::BTreeMap::new(),
315 meta: meta_enc,
316 };
317
318 let status = check_init_status(&vault, &secret).unwrap();
319 assert!(status.authorized);
320 assert_eq!(status.pubkey, pubkey);
321 assert_eq!(status.display_name.as_deref(), Some("Alice"));
322 }
323
324 #[test]
325 fn check_init_status_not_authorized() {
326 let (secret, pubkey) = generate_keypair();
327 let (_, other_pubkey) = generate_keypair();
328
329 let vault = types::Vault {
330 version: "2.0".into(),
331 created: "2026-01-01T00:00:00Z".into(),
332 vault_name: ".murk".into(),
333 repo: String::new(),
334 recipients: vec![other_pubkey],
335 schema: std::collections::BTreeMap::new(),
336 policy: None,
337 secrets: std::collections::BTreeMap::new(),
338 meta: String::new(),
339 };
340
341 let status = check_init_status(&vault, &secret).unwrap();
342 assert!(!status.authorized);
343 assert_eq!(status.pubkey, pubkey);
344 assert!(status.display_name.is_none());
345 }
346
347 #[test]
348 fn create_vault_basic() {
349 let (_, pubkey) = generate_keypair();
350
351 let vault = create_vault(".murk", &pubkey, "Bob").unwrap();
352 assert_eq!(vault.version, types::VAULT_VERSION);
353 assert_eq!(vault.vault_name, ".murk");
354 assert_eq!(vault.recipients, vec![pubkey]);
355 assert!(vault.schema.is_empty());
356 assert!(vault.secrets.is_empty());
357 assert!(!vault.meta.is_empty());
358 }
359
360 #[test]
363 fn sanitize_strips_https_credentials() {
364 assert_eq!(
365 sanitize_remote_url("https://user:pass@github.com/org/repo.git"),
366 "https://github.com/org/repo.git"
367 );
368 }
369
370 #[test]
371 fn sanitize_strips_https_token() {
372 assert_eq!(
373 sanitize_remote_url("https://ghp_abc123@github.com/org/repo.git"),
374 "https://github.com/org/repo.git"
375 );
376 }
377
378 #[test]
379 fn sanitize_preserves_clean_https() {
380 assert_eq!(
381 sanitize_remote_url("https://github.com/org/repo.git"),
382 "https://github.com/org/repo.git"
383 );
384 }
385
386 #[test]
387 fn sanitize_preserves_ssh() {
388 assert_eq!(
389 sanitize_remote_url("git@github.com:org/repo.git"),
390 "git@github.com:org/repo.git"
391 );
392 }
393
394 #[test]
395 fn sanitize_strips_http_credentials() {
396 assert_eq!(
397 sanitize_remote_url("http://user:pass@example.com/repo"),
398 "http://example.com/repo"
399 );
400 }
401}