Skip to main content

murk_cli/
init.rs

1//! Vault initialization logic.
2
3use std::collections::{BTreeMap, HashMap};
4use std::env;
5use std::process::Command;
6
7use crate::{crypto, encrypt_value, now_utc, types};
8
9/// Strip embedded credentials from a git remote URL.
10///
11/// Handles `https://user:pass@host/repo` → `https://host/repo` and
12/// `https://token@host/repo` → `https://host/repo`.
13/// SSH and other formats are returned as-is (no credentials to strip).
14fn 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            // Only strip if the '@' is before the first '/' (i.e. in the authority).
26            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/// A key discovered from the environment or .env file.
38#[derive(Debug)]
39pub struct DiscoveredKey {
40    pub secret_key: String,
41    pub pubkey: String,
42}
43
44/// Try to find an existing age key: checks `MURK_KEY` env var first,
45/// then falls back to `.env` file. Returns `None` if neither is set.
46pub 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/// Status of an existing vault relative to a given key.
66#[derive(Debug)]
67pub struct InitStatus {
68    /// Whether the key's pubkey is in the vault's recipient list.
69    pub authorized: bool,
70    /// The public key derived from the secret key.
71    pub pubkey: String,
72    /// Display name from encrypted meta, if decryptable and present.
73    pub display_name: Option<String>,
74}
75
76/// Check whether a secret key is authorized in an existing vault.
77///
78/// Parses the identity from `secret_key`, checks the recipient list, and
79/// attempts to decrypt meta for the display name.
80pub 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
100/// Create a new vault with a single recipient.
101///
102/// Detects the git remote URL and builds the initial vault struct.
103/// The caller is responsible for writing the vault to disk via `vault::write`.
104pub 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    // Detect git repo URL, stripping any embedded credentials.
117    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    // ── discover_existing_key tests ──
160
161    #[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        // Create a temp .env in a temp dir and chdir there.
181        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        // Use a dir with no .env.
204        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    // ── check_init_status tests ──
226
227    #[test]
228    fn check_init_status_authorized() {
229        let (secret, pubkey) = generate_keypair();
230        let recipient = make_recipient(&pubkey);
231
232        // Build a vault with this recipient in the list and encrypted meta.
233        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    // ── sanitize_remote_url tests ──
297
298    #[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}