Skip to main content

envvault/cli/commands/
search.rs

1//! `envvault search` — search secrets by name pattern.
2//!
3//! Supports simple glob matching: `*` matches any sequence, `?` matches one char.
4//! Matching is case-insensitive.
5
6use crate::cli::output;
7use crate::cli::{load_keyfile, prompt_password_for_vault, vault_path, Cli};
8use crate::errors::Result;
9use crate::vault::VaultStore;
10
11/// Execute the `search` command.
12pub fn execute(cli: &Cli, pattern: &str) -> Result<()> {
13    let path = vault_path(cli)?;
14    let keyfile = load_keyfile(cli)?;
15
16    let vault_id = path.to_string_lossy();
17    let password = prompt_password_for_vault(Some(&vault_id))?;
18    let store = VaultStore::open(&path, password.as_bytes(), keyfile.as_deref())?;
19
20    let secrets = store.list_secrets();
21    let matches: Vec<_> = secrets
22        .iter()
23        .filter(|s| glob_match(pattern, &s.name))
24        .collect();
25
26    if matches.is_empty() {
27        output::info(&format!("No secrets matching '{pattern}'"));
28        return Ok(());
29    }
30
31    output::info(&format!(
32        "{} secret(s) matching '{pattern}':",
33        matches.len()
34    ));
35    output::print_secrets_table(&matches.into_iter().cloned().collect::<Vec<_>>());
36
37    #[cfg(feature = "audit-log")]
38    crate::audit::log_read_audit(cli, "search", None, Some(&format!("pattern: {pattern}")));
39
40    Ok(())
41}
42
43/// Simple glob matcher supporting `*` (any sequence) and `?` (single char).
44/// Case-insensitive.
45pub fn glob_match(pattern: &str, text: &str) -> bool {
46    let pattern = pattern.to_ascii_lowercase();
47    let text = text.to_ascii_lowercase();
48    glob_match_inner(pattern.as_bytes(), text.as_bytes())
49}
50
51fn glob_match_inner(pattern: &[u8], text: &[u8]) -> bool {
52    let mut p = 0;
53    let mut t = 0;
54    let mut star_p = usize::MAX; // position in pattern after last '*'
55    let mut star_t = 0; // position in text when last '*' was matched
56
57    while t < text.len() {
58        if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == text[t]) {
59            p += 1;
60            t += 1;
61        } else if p < pattern.len() && pattern[p] == b'*' {
62            star_p = p + 1;
63            star_t = t;
64            p += 1;
65        } else if star_p != usize::MAX {
66            p = star_p;
67            star_t += 1;
68            t = star_t;
69        } else {
70            return false;
71        }
72    }
73
74    // Consume trailing '*' in pattern.
75    while p < pattern.len() && pattern[p] == b'*' {
76        p += 1;
77    }
78
79    p == pattern.len()
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn glob_exact_match() {
88        assert!(glob_match("DB_URL", "DB_URL"));
89        assert!(!glob_match("DB_URL", "DB_HOST"));
90    }
91
92    #[test]
93    fn glob_star_wildcard() {
94        assert!(glob_match("DB_*", "DB_URL"));
95        assert!(glob_match("DB_*", "DB_HOST"));
96        assert!(glob_match("*_KEY", "API_KEY"));
97        assert!(glob_match("*_KEY", "SECRET_KEY"));
98        assert!(!glob_match("DB_*", "API_KEY"));
99    }
100
101    #[test]
102    fn glob_question_wildcard() {
103        assert!(glob_match("DB_UR?", "DB_URL"));
104        assert!(!glob_match("DB_UR?", "DB_URLS"));
105    }
106
107    #[test]
108    fn glob_case_insensitive() {
109        assert!(glob_match("db_url", "DB_URL"));
110        assert!(glob_match("DB_URL", "db_url"));
111        assert!(glob_match("Db_*", "DB_URL"));
112    }
113
114    #[test]
115    fn glob_star_matches_empty() {
116        assert!(glob_match("*", "ANYTHING"));
117        assert!(glob_match("*", ""));
118        assert!(glob_match("DB_*", "DB_"));
119    }
120
121    #[test]
122    fn glob_multiple_stars() {
123        assert!(glob_match("*DB*", "MY_DB_URL"));
124        assert!(glob_match("*_*_*", "A_B_C"));
125    }
126}