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("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.to_public().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.to_public().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 mac = crate::compute_mac(&vault);
106    let meta = types::Meta {
107        recipients: recipient_names,
108        mac,
109    };
110    let meta_json = serde_json::to_vec(&meta).map_err(|e| e.to_string())?;
111    vault.meta = encrypt_value(&meta_json, &[recipient])?;
112
113    Ok(vault)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::testutil::*;
120    use std::sync::Mutex;
121
122    /// Tests that mutate MURK_KEY env var must hold this lock.
123    static ENV_LOCK: Mutex<()> = Mutex::new(());
124
125    // ── discover_existing_key tests ──
126
127    #[test]
128    fn discover_existing_key_from_env() {
129        let _lock = ENV_LOCK.lock().unwrap();
130        let (secret, pubkey) = generate_keypair();
131        unsafe { env::set_var("MURK_KEY", &secret) };
132        let result = discover_existing_key();
133        unsafe { env::remove_var("MURK_KEY") };
134
135        let dk = result.unwrap().unwrap();
136        assert_eq!(dk.secret_key, secret);
137        assert_eq!(dk.pubkey, pubkey);
138    }
139
140    #[test]
141    fn discover_existing_key_from_dotenv() {
142        let _lock = ENV_LOCK.lock().unwrap();
143        unsafe { env::remove_var("MURK_KEY") };
144
145        // Create a temp .env in a temp dir and chdir there.
146        let dir = std::env::temp_dir().join("murk_test_discover_dotenv");
147        std::fs::create_dir_all(&dir).unwrap();
148        let (secret, pubkey) = generate_keypair();
149        std::fs::write(dir.join(".env"), format!("MURK_KEY={secret}\n")).unwrap();
150
151        let orig_dir = std::env::current_dir().unwrap();
152        std::env::set_current_dir(&dir).unwrap();
153        let result = discover_existing_key();
154        std::env::set_current_dir(&orig_dir).unwrap();
155        std::fs::remove_dir_all(&dir).unwrap();
156
157        let dk = result.unwrap().unwrap();
158        assert_eq!(dk.secret_key, secret);
159        assert_eq!(dk.pubkey, pubkey);
160    }
161
162    #[test]
163    fn discover_existing_key_neither_set() {
164        let _lock = ENV_LOCK.lock().unwrap();
165        unsafe { env::remove_var("MURK_KEY") };
166
167        // Use a dir with no .env.
168        let dir = std::env::temp_dir().join("murk_test_discover_none");
169        std::fs::create_dir_all(&dir).unwrap();
170        let orig_dir = std::env::current_dir().unwrap();
171        std::env::set_current_dir(&dir).unwrap();
172        let result = discover_existing_key();
173        std::env::set_current_dir(&orig_dir).unwrap();
174        std::fs::remove_dir_all(&dir).unwrap();
175
176        assert!(result.unwrap().is_none());
177    }
178
179    #[test]
180    fn discover_existing_key_invalid_key() {
181        let _lock = ENV_LOCK.lock().unwrap();
182        unsafe { env::set_var("MURK_KEY", "not-a-valid-age-key") };
183        let result = discover_existing_key();
184        unsafe { env::remove_var("MURK_KEY") };
185
186        assert!(result.is_err());
187    }
188
189    // ── check_init_status tests ──
190
191    #[test]
192    fn check_init_status_authorized() {
193        let (secret, pubkey) = generate_keypair();
194        let recipient = make_recipient(&pubkey);
195
196        // Build a vault with this recipient in the list and encrypted meta.
197        let mut names = HashMap::new();
198        names.insert(pubkey.clone(), "Alice".to_string());
199        let meta = types::Meta {
200            recipients: names,
201            mac: String::new(),
202        };
203        let meta_json = serde_json::to_vec(&meta).unwrap();
204        let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap();
205
206        let vault = types::Vault {
207            version: "2.0".into(),
208            created: "2026-01-01T00:00:00Z".into(),
209            vault_name: ".murk".into(),
210            repo: String::new(),
211            recipients: vec![pubkey.clone()],
212            schema: std::collections::BTreeMap::new(),
213            secrets: std::collections::BTreeMap::new(),
214            meta: meta_enc,
215        };
216
217        let status = check_init_status(&vault, &secret).unwrap();
218        assert!(status.authorized);
219        assert_eq!(status.pubkey, pubkey);
220        assert_eq!(status.display_name.as_deref(), Some("Alice"));
221    }
222
223    #[test]
224    fn check_init_status_not_authorized() {
225        let (secret, pubkey) = generate_keypair();
226        let (_, other_pubkey) = generate_keypair();
227
228        let vault = types::Vault {
229            version: "2.0".into(),
230            created: "2026-01-01T00:00:00Z".into(),
231            vault_name: ".murk".into(),
232            repo: String::new(),
233            recipients: vec![other_pubkey],
234            schema: std::collections::BTreeMap::new(),
235            secrets: std::collections::BTreeMap::new(),
236            meta: String::new(),
237        };
238
239        let status = check_init_status(&vault, &secret).unwrap();
240        assert!(!status.authorized);
241        assert_eq!(status.pubkey, pubkey);
242        assert!(status.display_name.is_none());
243    }
244
245    #[test]
246    fn create_vault_basic() {
247        let (_, pubkey) = generate_keypair();
248
249        let vault = create_vault(".murk", &pubkey, "Bob").unwrap();
250        assert_eq!(vault.version, types::VAULT_VERSION);
251        assert_eq!(vault.vault_name, ".murk");
252        assert_eq!(vault.recipients, vec![pubkey]);
253        assert!(vault.schema.is_empty());
254        assert!(vault.secrets.is_empty());
255        assert!(!vault.meta.is_empty());
256    }
257}