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 from the environment.
45///
46/// Checks `MURK_KEY` first, then reads the file at `MURK_KEY_FILE` if set.
47/// Does NOT read `.env` — for direnv users, the shim already exports both
48/// variables into the environment, so the environment is the authoritative
49/// source and `.env` is only a write-only convenience populated by `murk init`.
50pub 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/// Status of an existing vault relative to a given key.
83#[derive(Debug)]
84pub struct InitStatus {
85    /// Whether the key's pubkey is in the vault's recipient list.
86    pub authorized: bool,
87    /// The public key derived from the secret key.
88    pub pubkey: String,
89    /// Display name from encrypted meta, if decryptable and present.
90    pub display_name: Option<String>,
91}
92
93/// Check whether a secret key is authorized in an existing vault.
94///
95/// Parses the identity from `secret_key`, checks the recipient list, and
96/// attempts to decrypt meta for the display name.
97pub 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
117/// Create a new vault with a single recipient.
118///
119/// Detects the git remote URL and builds the initial vault struct.
120/// The caller is responsible for writing the vault to disk via `vault::write`.
121pub 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    // Detect git repo URL, stripping any embedded credentials.
134    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    // ── discover_existing_key tests ──
177
178    #[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        // murk-82q: discover_existing_key must not read .env from CWD, even
196        // in the init flow. A .env sitting in the current directory with an
197        // inline MURK_KEY is explicitly *not* a trusted input source.
198        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        // Use a dir with no .env.
267        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    // ── check_init_status tests ──
291
292    #[test]
293    fn check_init_status_authorized() {
294        let (secret, pubkey) = generate_keypair();
295        let recipient = make_recipient(&pubkey);
296
297        // Build a vault with this recipient in the list and encrypted meta.
298        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    // ── sanitize_remote_url tests ──
362
363    #[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}