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