Skip to main content

murk_cli/
info.rs

1//! Vault info/introspection logic.
2
3use crate::{codename, types};
4
5/// Number of pubkey characters to show when a display name is unavailable.
6const PUBKEY_DISPLAY_LEN: usize = 12;
7
8/// A single key entry in the vault info output.
9#[derive(Debug)]
10pub struct InfoEntry {
11    pub key: String,
12    pub description: String,
13    pub example: Option<String>,
14    pub tags: Vec<String>,
15    /// Display names (or truncated pubkeys) of recipients with scoped overrides.
16    pub scoped_recipients: Vec<String>,
17}
18
19/// Aggregated vault information for display.
20#[derive(Debug)]
21pub struct VaultInfo {
22    pub vault_name: String,
23    pub codename: String,
24    pub repo: String,
25    pub created: String,
26    pub recipient_count: usize,
27    pub entries: Vec<InfoEntry>,
28}
29
30/// Compute vault info from raw vault bytes.
31///
32/// `raw_bytes` is the full file contents (for codename computation).
33/// `tags` filters entries by tag (empty = all).
34/// `secret_key` enables meta decryption for scoped-recipient display names.
35pub fn vault_info(
36    raw_bytes: &[u8],
37    tags: &[String],
38    secret_key: Option<&str>,
39) -> Result<VaultInfo, String> {
40    let vault: types::Vault = serde_json::from_slice(raw_bytes).map_err(|e| e.to_string())?;
41
42    let codename = codename::from_bytes(raw_bytes);
43
44    // Filter by tag if specified.
45    let filtered: Vec<(&String, &types::SchemaEntry)> = if tags.is_empty() {
46        vault.schema.iter().collect()
47    } else {
48        vault
49            .schema
50            .iter()
51            .filter(|(_, e)| e.tags.iter().any(|t| tags.contains(t)))
52            .collect()
53    };
54
55    // Try to decrypt meta for recipient names.
56    let meta_data = secret_key.and_then(|sk| {
57        let identity = crate::crypto::parse_identity(sk).ok()?;
58        crate::decrypt_meta(&vault, &identity)
59    });
60
61    let entries = filtered
62        .iter()
63        .map(|(key, entry)| {
64            let scoped_recipients = if let Some(ref meta) = meta_data {
65                vault
66                    .secrets
67                    .get(key.as_str())
68                    .map(|s| {
69                        s.scoped
70                            .keys()
71                            .map(|pk| {
72                                meta.recipients.get(pk).cloned().unwrap_or_else(|| {
73                                    pk.chars().take(PUBKEY_DISPLAY_LEN).collect::<String>()
74                                        + "\u{2026}"
75                                })
76                            })
77                            .collect()
78                    })
79                    .unwrap_or_default()
80            } else {
81                vec![]
82            };
83
84            InfoEntry {
85                key: (*key).clone(),
86                description: entry.description.clone(),
87                example: entry.example.clone(),
88                tags: entry.tags.clone(),
89                scoped_recipients,
90            }
91        })
92        .collect();
93
94    Ok(VaultInfo {
95        vault_name: vault.vault_name.clone(),
96        codename,
97        repo: vault.repo.clone(),
98        created: vault.created.clone(),
99        recipient_count: vault.recipients.len(),
100        entries,
101    })
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::collections::BTreeMap;
108
109    fn test_vault_bytes(schema: BTreeMap<String, types::SchemaEntry>) -> Vec<u8> {
110        let vault = types::Vault {
111            version: types::VAULT_VERSION.into(),
112            created: "2026-01-01T00:00:00Z".into(),
113            vault_name: ".murk".into(),
114            repo: "https://github.com/test/repo".into(),
115            recipients: vec!["age1test".into()],
116            schema,
117            secrets: BTreeMap::new(),
118            meta: String::new(),
119        };
120        serde_json::to_vec(&vault).unwrap()
121    }
122
123    #[test]
124    fn vault_info_basic() {
125        let mut schema = BTreeMap::new();
126        schema.insert(
127            "DB_URL".into(),
128            types::SchemaEntry {
129                description: "database url".into(),
130                example: Some("postgres://...".into()),
131                tags: vec!["db".into()],
132            },
133        );
134        let bytes = test_vault_bytes(schema);
135
136        let info = vault_info(&bytes, &[], None).unwrap();
137        assert_eq!(info.vault_name, ".murk");
138        assert!(!info.codename.is_empty());
139        assert_eq!(info.repo, "https://github.com/test/repo");
140        assert_eq!(info.recipient_count, 1);
141        assert_eq!(info.entries.len(), 1);
142        assert_eq!(info.entries[0].key, "DB_URL");
143        assert_eq!(info.entries[0].description, "database url");
144        assert_eq!(info.entries[0].example.as_deref(), Some("postgres://..."));
145    }
146
147    #[test]
148    fn vault_info_tag_filter() {
149        let mut schema = BTreeMap::new();
150        schema.insert(
151            "DB_URL".into(),
152            types::SchemaEntry {
153                description: "db".into(),
154                example: None,
155                tags: vec!["db".into()],
156            },
157        );
158        schema.insert(
159            "API_KEY".into(),
160            types::SchemaEntry {
161                description: "api".into(),
162                example: None,
163                tags: vec!["api".into()],
164            },
165        );
166        let bytes = test_vault_bytes(schema);
167
168        let info = vault_info(&bytes, &["db".into()], None).unwrap();
169        assert_eq!(info.entries.len(), 1);
170        assert_eq!(info.entries[0].key, "DB_URL");
171    }
172
173    #[test]
174    fn vault_info_empty_schema() {
175        let bytes = test_vault_bytes(BTreeMap::new());
176        let info = vault_info(&bytes, &[], None).unwrap();
177        assert!(info.entries.is_empty());
178    }
179
180    #[test]
181    fn vault_info_invalid_json() {
182        let result = vault_info(b"not json", &[], None);
183        assert!(result.is_err());
184    }
185
186    #[test]
187    fn vault_info_valid_json_missing_fields() {
188        // Valid JSON but not a vault — should fail deserialization.
189        let result = vault_info(b"{\"foo\": \"bar\"}", &[], None);
190        assert!(result.is_err());
191    }
192}