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> {
47 let raw = env::var(crate::env::ENV_MURK_KEY)
48 .ok()
49 .filter(|k| !k.is_empty())
50 .or_else(crate::read_key_from_dotenv);
51
52 match raw {
53 Some(key) => {
54 let identity = crypto::parse_identity(&key).map_err(|e| e.to_string())?;
55 let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
56 Ok(Some(DiscoveredKey {
57 secret_key: key,
58 pubkey,
59 }))
60 }
61 None => Ok(None),
62 }
63}
64
65#[derive(Debug)]
67pub struct InitStatus {
68 pub authorized: bool,
70 pub pubkey: String,
72 pub display_name: Option<String>,
74}
75
76pub fn check_init_status(vault: &types::Vault, secret_key: &str) -> Result<InitStatus, String> {
81 let identity = crypto::parse_identity(secret_key).map_err(|e| e.to_string())?;
82 let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
83 let authorized = vault.recipients.contains(&pubkey);
84
85 let display_name = if authorized {
86 crate::decrypt_meta(vault, &identity)
87 .and_then(|meta| meta.recipients.get(&pubkey).cloned())
88 .filter(|name| !name.is_empty())
89 } else {
90 None
91 };
92
93 Ok(InitStatus {
94 authorized,
95 pubkey,
96 display_name,
97 })
98}
99
100pub fn create_vault(
105 vault_name: &str,
106 pubkey: &str,
107 name: &str,
108) -> Result<types::Vault, crate::error::MurkError> {
109 use crate::error::MurkError;
110
111 let mut recipient_names = HashMap::new();
112 recipient_names.insert(pubkey.to_string(), name.to_string());
113
114 let recipient = crypto::parse_recipient(pubkey)?;
115
116 let repo = Command::new("git")
118 .args(["remote", "get-url", "origin"])
119 .output()
120 .ok()
121 .filter(|o| o.status.success())
122 .and_then(|o| String::from_utf8(o.stdout).ok())
123 .map(|s| sanitize_remote_url(s.trim()))
124 .unwrap_or_default();
125
126 let mut vault = types::Vault {
127 version: types::VAULT_VERSION.into(),
128 created: now_utc(),
129 vault_name: vault_name.into(),
130 repo,
131 recipients: vec![pubkey.to_string()],
132 schema: BTreeMap::new(),
133 secrets: BTreeMap::new(),
134 meta: String::new(),
135 };
136
137 let mac_key_hex = crate::generate_mac_key();
138 let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
139 let mac = crate::compute_mac(&vault, Some(&mac_key));
140 let meta = types::Meta {
141 recipients: recipient_names,
142 mac,
143 mac_key: Some(mac_key_hex),
144 github_pins: HashMap::new(),
145 };
146 let meta_json =
147 serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
148 vault.meta = encrypt_value(&meta_json, &[recipient])?;
149
150 Ok(vault)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::testutil::*;
157 use crate::testutil::{CWD_LOCK, ENV_LOCK};
158
159 #[test]
162 fn discover_existing_key_from_env() {
163 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
164 let (secret, pubkey) = generate_keypair();
165 unsafe { env::set_var("MURK_KEY", &secret) };
166 let result = discover_existing_key();
167 unsafe { env::remove_var("MURK_KEY") };
168
169 let dk = result.unwrap().unwrap();
170 assert_eq!(dk.secret_key, secret);
171 assert_eq!(dk.pubkey, pubkey);
172 }
173
174 #[test]
175 fn discover_existing_key_from_dotenv() {
176 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
177 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
178 unsafe { env::remove_var("MURK_KEY") };
179
180 let dir = std::env::temp_dir().join("murk_test_discover_dotenv");
182 std::fs::create_dir_all(&dir).unwrap();
183 let (secret, pubkey) = generate_keypair();
184 std::fs::write(dir.join(".env"), format!("MURK_KEY={secret}\n")).unwrap();
185
186 let orig_dir = std::env::current_dir().unwrap();
187 std::env::set_current_dir(&dir).unwrap();
188 let result = discover_existing_key();
189 std::env::set_current_dir(&orig_dir).unwrap();
190 std::fs::remove_dir_all(&dir).unwrap();
191
192 let dk = result.unwrap().unwrap();
193 assert_eq!(dk.secret_key, secret);
194 assert_eq!(dk.pubkey, pubkey);
195 }
196
197 #[test]
198 fn discover_existing_key_neither_set() {
199 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
200 let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
201 unsafe { env::remove_var("MURK_KEY") };
202
203 let dir = std::env::temp_dir().join("murk_test_discover_none");
205 std::fs::create_dir_all(&dir).unwrap();
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!(result.unwrap().is_none());
213 }
214
215 #[test]
216 fn discover_existing_key_invalid_key() {
217 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
218 unsafe { env::set_var("MURK_KEY", "not-a-valid-age-key") };
219 let result = discover_existing_key();
220 unsafe { env::remove_var("MURK_KEY") };
221
222 assert!(result.is_err());
223 }
224
225 #[test]
228 fn check_init_status_authorized() {
229 let (secret, pubkey) = generate_keypair();
230 let recipient = make_recipient(&pubkey);
231
232 let mut names = HashMap::new();
234 names.insert(pubkey.clone(), "Alice".to_string());
235 let meta = types::Meta {
236 recipients: names,
237 mac: String::new(),
238 mac_key: None,
239 github_pins: HashMap::new(),
240 };
241 let meta_json = serde_json::to_vec(&meta).unwrap();
242 let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap();
243
244 let vault = types::Vault {
245 version: "2.0".into(),
246 created: "2026-01-01T00:00:00Z".into(),
247 vault_name: ".murk".into(),
248 repo: String::new(),
249 recipients: vec![pubkey.clone()],
250 schema: std::collections::BTreeMap::new(),
251 secrets: std::collections::BTreeMap::new(),
252 meta: meta_enc,
253 };
254
255 let status = check_init_status(&vault, &secret).unwrap();
256 assert!(status.authorized);
257 assert_eq!(status.pubkey, pubkey);
258 assert_eq!(status.display_name.as_deref(), Some("Alice"));
259 }
260
261 #[test]
262 fn check_init_status_not_authorized() {
263 let (secret, pubkey) = generate_keypair();
264 let (_, other_pubkey) = generate_keypair();
265
266 let vault = types::Vault {
267 version: "2.0".into(),
268 created: "2026-01-01T00:00:00Z".into(),
269 vault_name: ".murk".into(),
270 repo: String::new(),
271 recipients: vec![other_pubkey],
272 schema: std::collections::BTreeMap::new(),
273 secrets: std::collections::BTreeMap::new(),
274 meta: String::new(),
275 };
276
277 let status = check_init_status(&vault, &secret).unwrap();
278 assert!(!status.authorized);
279 assert_eq!(status.pubkey, pubkey);
280 assert!(status.display_name.is_none());
281 }
282
283 #[test]
284 fn create_vault_basic() {
285 let (_, pubkey) = generate_keypair();
286
287 let vault = create_vault(".murk", &pubkey, "Bob").unwrap();
288 assert_eq!(vault.version, types::VAULT_VERSION);
289 assert_eq!(vault.vault_name, ".murk");
290 assert_eq!(vault.recipients, vec![pubkey]);
291 assert!(vault.schema.is_empty());
292 assert!(vault.secrets.is_empty());
293 assert!(!vault.meta.is_empty());
294 }
295
296 #[test]
299 fn sanitize_strips_https_credentials() {
300 assert_eq!(
301 sanitize_remote_url("https://user:pass@github.com/org/repo.git"),
302 "https://github.com/org/repo.git"
303 );
304 }
305
306 #[test]
307 fn sanitize_strips_https_token() {
308 assert_eq!(
309 sanitize_remote_url("https://ghp_abc123@github.com/org/repo.git"),
310 "https://github.com/org/repo.git"
311 );
312 }
313
314 #[test]
315 fn sanitize_preserves_clean_https() {
316 assert_eq!(
317 sanitize_remote_url("https://github.com/org/repo.git"),
318 "https://github.com/org/repo.git"
319 );
320 }
321
322 #[test]
323 fn sanitize_preserves_ssh() {
324 assert_eq!(
325 sanitize_remote_url("git@github.com:org/repo.git"),
326 "git@github.com:org/repo.git"
327 );
328 }
329
330 #[test]
331 fn sanitize_strips_http_credentials() {
332 assert_eq!(
333 sanitize_remote_url("http://user:pass@example.com/repo"),
334 "http://example.com/repo"
335 );
336 }
337}