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/// A key discovered from the environment or .env file.
10#[derive(Debug)]
11pub struct DiscoveredKey {
12    pub secret_key: String,
13    pub pubkey: String,
14}
15
16/// Try to find an existing age key: checks `MURK_KEY` env var first,
17/// then falls back to `.env` file. Returns `None` if neither is set.
18pub fn discover_existing_key() -> Result<Option<DiscoveredKey>, String> {
19    let raw = env::var(crate::env::ENV_MURK_KEY)
20        .ok()
21        .filter(|k| !k.is_empty())
22        .or_else(crate::read_key_from_dotenv);
23
24    match raw {
25        Some(key) => {
26            let identity = crypto::parse_identity(&key).map_err(|e| e.to_string())?;
27            let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
28            Ok(Some(DiscoveredKey {
29                secret_key: key,
30                pubkey,
31            }))
32        }
33        None => Ok(None),
34    }
35}
36
37/// Status of an existing vault relative to a given key.
38#[derive(Debug)]
39pub struct InitStatus {
40    /// Whether the key's pubkey is in the vault's recipient list.
41    pub authorized: bool,
42    /// The public key derived from the secret key.
43    pub pubkey: String,
44    /// Display name from encrypted meta, if decryptable and present.
45    pub display_name: Option<String>,
46}
47
48/// Check whether a secret key is authorized in an existing vault.
49///
50/// Parses the identity from `secret_key`, checks the recipient list, and
51/// attempts to decrypt meta for the display name.
52pub fn check_init_status(vault: &types::Vault, secret_key: &str) -> Result<InitStatus, String> {
53    let identity = crypto::parse_identity(secret_key).map_err(|e| e.to_string())?;
54    let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
55    let authorized = vault.recipients.contains(&pubkey);
56
57    let display_name = if authorized {
58        crate::decrypt_meta(vault, &identity)
59            .and_then(|meta| meta.recipients.get(&pubkey).cloned())
60            .filter(|name| !name.is_empty())
61    } else {
62        None
63    };
64
65    Ok(InitStatus {
66        authorized,
67        pubkey,
68        display_name,
69    })
70}
71
72/// Create a new vault with a single recipient.
73///
74/// Detects the git remote URL and builds the initial vault struct.
75/// The caller is responsible for writing the vault to disk via `vault::write`.
76pub fn create_vault(vault_name: &str, pubkey: &str, name: &str) -> Result<types::Vault, String> {
77    // Build meta with the recipient name mapping.
78    let mut recipient_names = HashMap::new();
79    recipient_names.insert(pubkey.to_string(), name.to_string());
80
81    let recipient = crypto::parse_recipient(pubkey).map_err(|e| e.to_string())?;
82
83    // Detect git repo URL.
84    let repo = Command::new("git")
85        .args(["remote", "get-url", "origin"])
86        .output()
87        .ok()
88        .filter(|o| o.status.success())
89        .and_then(|o| String::from_utf8(o.stdout).ok())
90        .map(|s| s.trim().to_string())
91        .unwrap_or_default();
92
93    // Build the vault first so we can compute a real MAC over it.
94    let mut vault = types::Vault {
95        version: types::VAULT_VERSION.into(),
96        created: now_utc(),
97        vault_name: vault_name.into(),
98        repo,
99        recipients: vec![pubkey.to_string()],
100        schema: BTreeMap::new(),
101        secrets: BTreeMap::new(),
102        meta: String::new(),
103    };
104
105    let hmac_key_hex = crate::generate_hmac_key();
106    let hmac_key = crate::decode_hmac_key(&hmac_key_hex).unwrap();
107    let mac = crate::compute_mac(&vault, Some(&hmac_key));
108    let meta = types::Meta {
109        recipients: recipient_names,
110        mac,
111        hmac_key: Some(hmac_key_hex),
112    };
113    let meta_json = serde_json::to_vec(&meta).map_err(|e| e.to_string())?;
114    vault.meta = encrypt_value(&meta_json, &[recipient])?;
115
116    Ok(vault)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::testutil::*;
123    use std::sync::Mutex;
124
125    /// Tests that mutate MURK_KEY env var must hold this lock.
126    static ENV_LOCK: Mutex<()> = Mutex::new(());
127
128    // ── discover_existing_key tests ──
129
130    #[test]
131    fn discover_existing_key_from_env() {
132        let _lock = ENV_LOCK.lock().unwrap();
133        let (secret, pubkey) = generate_keypair();
134        unsafe { env::set_var("MURK_KEY", &secret) };
135        let result = discover_existing_key();
136        unsafe { env::remove_var("MURK_KEY") };
137
138        let dk = result.unwrap().unwrap();
139        assert_eq!(dk.secret_key, secret);
140        assert_eq!(dk.pubkey, pubkey);
141    }
142
143    #[test]
144    fn discover_existing_key_from_dotenv() {
145        let _lock = ENV_LOCK.lock().unwrap();
146        unsafe { env::remove_var("MURK_KEY") };
147
148        // Create a temp .env in a temp dir and chdir there.
149        let dir = std::env::temp_dir().join("murk_test_discover_dotenv");
150        std::fs::create_dir_all(&dir).unwrap();
151        let (secret, pubkey) = generate_keypair();
152        std::fs::write(dir.join(".env"), format!("MURK_KEY={secret}\n")).unwrap();
153
154        let orig_dir = std::env::current_dir().unwrap();
155        std::env::set_current_dir(&dir).unwrap();
156        let result = discover_existing_key();
157        std::env::set_current_dir(&orig_dir).unwrap();
158        std::fs::remove_dir_all(&dir).unwrap();
159
160        let dk = result.unwrap().unwrap();
161        assert_eq!(dk.secret_key, secret);
162        assert_eq!(dk.pubkey, pubkey);
163    }
164
165    #[test]
166    fn discover_existing_key_neither_set() {
167        let _lock = ENV_LOCK.lock().unwrap();
168        unsafe { env::remove_var("MURK_KEY") };
169
170        // Use a dir with no .env.
171        let dir = std::env::temp_dir().join("murk_test_discover_none");
172        std::fs::create_dir_all(&dir).unwrap();
173        let orig_dir = std::env::current_dir().unwrap();
174        std::env::set_current_dir(&dir).unwrap();
175        let result = discover_existing_key();
176        std::env::set_current_dir(&orig_dir).unwrap();
177        std::fs::remove_dir_all(&dir).unwrap();
178
179        assert!(result.unwrap().is_none());
180    }
181
182    #[test]
183    fn discover_existing_key_invalid_key() {
184        let _lock = ENV_LOCK.lock().unwrap();
185        unsafe { env::set_var("MURK_KEY", "not-a-valid-age-key") };
186        let result = discover_existing_key();
187        unsafe { env::remove_var("MURK_KEY") };
188
189        assert!(result.is_err());
190    }
191
192    // ── check_init_status tests ──
193
194    #[test]
195    fn check_init_status_authorized() {
196        let (secret, pubkey) = generate_keypair();
197        let recipient = make_recipient(&pubkey);
198
199        // Build a vault with this recipient in the list and encrypted meta.
200        let mut names = HashMap::new();
201        names.insert(pubkey.clone(), "Alice".to_string());
202        let meta = types::Meta {
203            recipients: names,
204            mac: String::new(),
205            hmac_key: None,
206        };
207        let meta_json = serde_json::to_vec(&meta).unwrap();
208        let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap();
209
210        let vault = types::Vault {
211            version: "2.0".into(),
212            created: "2026-01-01T00:00:00Z".into(),
213            vault_name: ".murk".into(),
214            repo: String::new(),
215            recipients: vec![pubkey.clone()],
216            schema: std::collections::BTreeMap::new(),
217            secrets: std::collections::BTreeMap::new(),
218            meta: meta_enc,
219        };
220
221        let status = check_init_status(&vault, &secret).unwrap();
222        assert!(status.authorized);
223        assert_eq!(status.pubkey, pubkey);
224        assert_eq!(status.display_name.as_deref(), Some("Alice"));
225    }
226
227    #[test]
228    fn check_init_status_not_authorized() {
229        let (secret, pubkey) = generate_keypair();
230        let (_, other_pubkey) = generate_keypair();
231
232        let vault = types::Vault {
233            version: "2.0".into(),
234            created: "2026-01-01T00:00:00Z".into(),
235            vault_name: ".murk".into(),
236            repo: String::new(),
237            recipients: vec![other_pubkey],
238            schema: std::collections::BTreeMap::new(),
239            secrets: std::collections::BTreeMap::new(),
240            meta: String::new(),
241        };
242
243        let status = check_init_status(&vault, &secret).unwrap();
244        assert!(!status.authorized);
245        assert_eq!(status.pubkey, pubkey);
246        assert!(status.display_name.is_none());
247    }
248
249    #[test]
250    fn create_vault_basic() {
251        let (_, pubkey) = generate_keypair();
252
253        let vault = create_vault(".murk", &pubkey, "Bob").unwrap();
254        assert_eq!(vault.version, types::VAULT_VERSION);
255        assert_eq!(vault.vault_name, ".murk");
256        assert_eq!(vault.recipients, vec![pubkey]);
257        assert!(vault.schema.is_empty());
258        assert!(vault.secrets.is_empty());
259        assert!(!vault.meta.is_empty());
260    }
261}