Skip to main content

murk_cli/
init.rs

1//! Vault initialization logic.
2
3use std::collections::{BTreeMap, HashMap};
4use std::process::Command;
5// Only the tests touch the process environment directly; the runtime key read
6// now goes through env::key_from_env_only (see tests/invariants.rs).
7#[cfg(test)]
8use std::env;
9
10use crate::{crypto, encrypt_value, now_utc, types};
11
12/// Strip embedded credentials from a git remote URL.
13///
14/// Handles `https://user:pass@host/repo` → `https://host/repo` and
15/// `https://token@host/repo` → `https://host/repo`.
16/// SSH and other formats are returned as-is (no credentials to strip).
17fn 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            // Only strip if the '@' is before the first '/' (i.e. in the authority).
29            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/// A key discovered from the environment or .env file.
41#[derive(Debug)]
42pub struct DiscoveredKey {
43    pub secret_key: String,
44    pub pubkey: String,
45}
46
47/// Try to find an existing age key from the environment.
48///
49/// Checks `MURK_KEY` first, then reads the file at `MURK_KEY_FILE` if set.
50/// Does NOT read `.env` — for direnv users, the shim already exports both
51/// variables into the environment, so the environment is the authoritative
52/// source and `.env` is only a write-only convenience populated by `murk init`.
53pub fn discover_existing_key() -> Result<Option<DiscoveredKey>, String> {
54    // The env vars are read in one place (the env module) so the auth read path
55    // stays auditable — see tests/invariants.rs.
56    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/// Status of an existing vault relative to a given key.
72#[derive(Debug)]
73pub struct InitStatus {
74    /// Whether the key's pubkey is in the vault's recipient list.
75    pub authorized: bool,
76    /// The public key derived from the secret key.
77    pub pubkey: String,
78    /// Display name from encrypted meta, if decryptable and present.
79    pub display_name: Option<String>,
80}
81
82/// Check whether a secret key is authorized in an existing vault.
83///
84/// Parses the identity from `secret_key`, checks the recipient list, and
85/// attempts to decrypt meta for the display name.
86pub 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
106/// Create a new vault with a single recipient.
107///
108/// Detects the git remote URL and builds the initial vault struct.
109/// The caller is responsible for writing the vault to disk via `vault::write`.
110pub 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    // Detect git repo URL, stripping any embedded credentials.
123    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    // The initial vault is unsigned: `create_vault` holds only the public key,
148    // and an empty vault has nothing to protect. The first secret write signs it.
149    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    // ── discover_existing_key tests ──
173
174    #[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        // murk-82q: discover_existing_key must not read .env from CWD, even
192        // in the init flow. A .env sitting in the current directory with an
193        // inline MURK_KEY is explicitly *not* a trusted input source.
194        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        // Use a dir with no .env.
263        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    // ── check_init_status tests ──
287
288    #[test]
289    fn check_init_status_authorized() {
290        let (secret, pubkey) = generate_keypair();
291        let recipient = make_recipient(&pubkey);
292
293        // Build a vault with this recipient in the list and encrypted meta.
294        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    // ── sanitize_remote_url tests ──
361
362    #[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}