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(
77    vault_name: &str,
78    pubkey: &str,
79    name: &str,
80) -> Result<types::Vault, crate::error::MurkError> {
81    use crate::error::MurkError;
82
83    let mut recipient_names = HashMap::new();
84    recipient_names.insert(pubkey.to_string(), name.to_string());
85
86    let recipient = crypto::parse_recipient(pubkey)?;
87
88    // Detect git repo URL.
89    let repo = Command::new("git")
90        .args(["remote", "get-url", "origin"])
91        .output()
92        .ok()
93        .filter(|o| o.status.success())
94        .and_then(|o| String::from_utf8(o.stdout).ok())
95        .map(|s| s.trim().to_string())
96        .unwrap_or_default();
97
98    let mut vault = types::Vault {
99        version: types::VAULT_VERSION.into(),
100        created: now_utc(),
101        vault_name: vault_name.into(),
102        repo,
103        recipients: vec![pubkey.to_string()],
104        schema: BTreeMap::new(),
105        secrets: BTreeMap::new(),
106        meta: String::new(),
107    };
108
109    let hmac_key_hex = crate::generate_hmac_key();
110    let hmac_key = crate::decode_hmac_key(&hmac_key_hex).unwrap();
111    let mac = crate::compute_mac(&vault, Some(&hmac_key));
112    let meta = types::Meta {
113        recipients: recipient_names,
114        mac,
115        hmac_key: Some(hmac_key_hex),
116    };
117    let meta_json =
118        serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
119    vault.meta = encrypt_value(&meta_json, &[recipient])?;
120
121    Ok(vault)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::testutil::*;
128    use crate::testutil::{CWD_LOCK, ENV_LOCK};
129
130    // ── discover_existing_key tests ──
131
132    #[test]
133    fn discover_existing_key_from_env() {
134        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
135        let (secret, pubkey) = generate_keypair();
136        unsafe { env::set_var("MURK_KEY", &secret) };
137        let result = discover_existing_key();
138        unsafe { env::remove_var("MURK_KEY") };
139
140        let dk = result.unwrap().unwrap();
141        assert_eq!(dk.secret_key, secret);
142        assert_eq!(dk.pubkey, pubkey);
143    }
144
145    #[test]
146    fn discover_existing_key_from_dotenv() {
147        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
148        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
149        unsafe { env::remove_var("MURK_KEY") };
150
151        // Create a temp .env in a temp dir and chdir there.
152        let dir = std::env::temp_dir().join("murk_test_discover_dotenv");
153        std::fs::create_dir_all(&dir).unwrap();
154        let (secret, pubkey) = generate_keypair();
155        std::fs::write(dir.join(".env"), format!("MURK_KEY={secret}\n")).unwrap();
156
157        let orig_dir = std::env::current_dir().unwrap();
158        std::env::set_current_dir(&dir).unwrap();
159        let result = discover_existing_key();
160        std::env::set_current_dir(&orig_dir).unwrap();
161        std::fs::remove_dir_all(&dir).unwrap();
162
163        let dk = result.unwrap().unwrap();
164        assert_eq!(dk.secret_key, secret);
165        assert_eq!(dk.pubkey, pubkey);
166    }
167
168    #[test]
169    fn discover_existing_key_neither_set() {
170        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
171        let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
172        unsafe { env::remove_var("MURK_KEY") };
173
174        // Use a dir with no .env.
175        let dir = std::env::temp_dir().join("murk_test_discover_none");
176        std::fs::create_dir_all(&dir).unwrap();
177        let orig_dir = std::env::current_dir().unwrap();
178        std::env::set_current_dir(&dir).unwrap();
179        let result = discover_existing_key();
180        std::env::set_current_dir(&orig_dir).unwrap();
181        std::fs::remove_dir_all(&dir).unwrap();
182
183        assert!(result.unwrap().is_none());
184    }
185
186    #[test]
187    fn discover_existing_key_invalid_key() {
188        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
189        unsafe { env::set_var("MURK_KEY", "not-a-valid-age-key") };
190        let result = discover_existing_key();
191        unsafe { env::remove_var("MURK_KEY") };
192
193        assert!(result.is_err());
194    }
195
196    // ── check_init_status tests ──
197
198    #[test]
199    fn check_init_status_authorized() {
200        let (secret, pubkey) = generate_keypair();
201        let recipient = make_recipient(&pubkey);
202
203        // Build a vault with this recipient in the list and encrypted meta.
204        let mut names = HashMap::new();
205        names.insert(pubkey.clone(), "Alice".to_string());
206        let meta = types::Meta {
207            recipients: names,
208            mac: String::new(),
209            hmac_key: None,
210        };
211        let meta_json = serde_json::to_vec(&meta).unwrap();
212        let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap();
213
214        let vault = types::Vault {
215            version: "2.0".into(),
216            created: "2026-01-01T00:00:00Z".into(),
217            vault_name: ".murk".into(),
218            repo: String::new(),
219            recipients: vec![pubkey.clone()],
220            schema: std::collections::BTreeMap::new(),
221            secrets: std::collections::BTreeMap::new(),
222            meta: meta_enc,
223        };
224
225        let status = check_init_status(&vault, &secret).unwrap();
226        assert!(status.authorized);
227        assert_eq!(status.pubkey, pubkey);
228        assert_eq!(status.display_name.as_deref(), Some("Alice"));
229    }
230
231    #[test]
232    fn check_init_status_not_authorized() {
233        let (secret, pubkey) = generate_keypair();
234        let (_, other_pubkey) = generate_keypair();
235
236        let vault = types::Vault {
237            version: "2.0".into(),
238            created: "2026-01-01T00:00:00Z".into(),
239            vault_name: ".murk".into(),
240            repo: String::new(),
241            recipients: vec![other_pubkey],
242            schema: std::collections::BTreeMap::new(),
243            secrets: std::collections::BTreeMap::new(),
244            meta: String::new(),
245        };
246
247        let status = check_init_status(&vault, &secret).unwrap();
248        assert!(!status.authorized);
249        assert_eq!(status.pubkey, pubkey);
250        assert!(status.display_name.is_none());
251    }
252
253    #[test]
254    fn create_vault_basic() {
255        let (_, pubkey) = generate_keypair();
256
257        let vault = create_vault(".murk", &pubkey, "Bob").unwrap();
258        assert_eq!(vault.version, types::VAULT_VERSION);
259        assert_eq!(vault.vault_name, ".murk");
260        assert_eq!(vault.recipients, vec![pubkey]);
261        assert!(vault.schema.is_empty());
262        assert!(vault.secrets.is_empty());
263        assert!(!vault.meta.is_empty());
264    }
265}