Skip to main content

elasticctl_api/
selection.rs

1//! Resolve user-facing selectors to stable `rule_id` values.
2//!
3//! One resolver serves `rules export`, `delete`, `enable`, and state commands.
4//! This keeps ambiguous-name and empty-match behavior consistent.
5//!
6//! Identity is always `rule_id`. Names only find IDs.
7
8use crate::model::Rule;
9use crate::rules::{self, RuleFilter};
10use elasticctl_core::{Error, ErrorKind, Result, Transport};
11
12/// Displays an unreadable `rule_id`, such as a non-string value from the
13/// server.
14pub const UNREADABLE_RULE_ID: &str = "<unreadable rule_id>";
15
16/// Same-named candidates to consider in one page. The cap prevents a
17/// pathological name lookup from becoming a corpus read.
18pub const NAME_SEARCH_LIMIT: u32 = 100;
19
20/// Match names exactly. A prefix match could target the wrong rule.
21pub fn pick_by_name(found: &[Rule], name: &str) -> Result<String> {
22    let matches: Vec<&Rule> = found.iter().filter(|r| r.name() == name).collect();
23
24    match matches.len() {
25        1 => Ok(matches[0].rule_id()?.to_string()),
26        0 => Err(Error::new(
27            ErrorKind::NotFound,
28            // The selector might be an absent `rule_id`; reporting a missed
29            // name would misidentify the problem.
30            format!("No rule with rule_id or name '{name}'"),
31        )),
32        _ => {
33            // List every candidate, including unreadable IDs, so the count and
34            // list agree.
35            let ids: Vec<&str> = matches
36                .iter()
37                .map(|r| r.rule_id().unwrap_or(UNREADABLE_RULE_ID))
38                .collect();
39            Err(Error::new(
40                ErrorKind::Conflict,
41                format!(
42                    "{} rules are named '{name}'. Select one by rule_id: {}",
43                    matches.len(),
44                    ids.join(", ")
45                ),
46            ))
47        }
48    }
49}
50
51/// Run `pick_by_name` on a candidate page with the server's candidate total.
52///
53/// The KQL filter can return near matches on an analyzed field, so compare
54/// exactly here. If the page is truncated with no exact match, report the cap
55/// instead of claiming the name is absent.
56pub fn pick_by_name_capped(found: &[Rule], name: &str, total: u64) -> Result<String> {
57    match pick_by_name(found, name) {
58        Err(e) if e.kind == ErrorKind::NotFound && total > found.len() as u64 => Err(Error::new(
59            ErrorKind::NotFound,
60            format!(
61                "No rule with rule_id or name '{name}' among the first {} of {total} \
62                     candidates; narrow the name",
63                found.len()
64            ),
65        )),
66        other => other,
67    }
68}
69
70/// Resolve a `rule_id` or display name against the stack. Try `rule_id` first
71/// because it is unambiguous.
72///
73/// Name lookup uses one server-filtered `_find`. Walking 2,066 rules took 8.8
74/// seconds only to report no match.
75pub async fn to_rule_id(t: &Transport, selector: &str) -> Result<String> {
76    match rules::get(t, selector).await {
77        Ok(r) => return Ok(r.rule_id()?.to_string()),
78        Err(e) if e.kind != ErrorKind::NotFound => return Err(e),
79        Err(_) => {}
80    }
81
82    let filter = RuleFilter {
83        name: Some(selector.to_string()),
84        ..Default::default()
85    };
86    let (candidates, total) = rules::find_page(t, &filter, 1, NAME_SEARCH_LIMIT).await?;
87    pick_by_name_capped(&candidates, selector, total)
88}
89
90/// Match a selector against local rules before querying the stack.
91///
92/// `None` means no local match, so the caller queries the stack.
93/// `Some(Err(..))` indicates an ambiguous local name.
94fn local_match(local: &[Rule], selector: &str) -> Option<Result<String>> {
95    if local.iter().any(|r| r.rule_id().ok() == Some(selector)) {
96        return Some(Ok(selector.to_string()));
97    }
98
99    let named: Vec<&Rule> = local.iter().filter(|r| r.name() == selector).collect();
100    match named.len() {
101        0 => None,
102        1 => Some(named[0].rule_id().map(str::to_string)),
103        _ => {
104            let ids: Vec<&str> = named
105                .iter()
106                .map(|r| r.rule_id().unwrap_or(UNREADABLE_RULE_ID))
107                .collect();
108            Some(Err(Error::new(
109                ErrorKind::Conflict,
110                format!(
111                    "{} local rules are named '{selector}'. Select one by rule_id: {}",
112                    named.len(),
113                    ids.join(", ")
114                ),
115            )))
116        }
117    }
118}
119
120/// Whether `rule` matches a `--search` text: a case-insensitive name substring
121/// or an exact tag.
122///
123/// The server-side KQL (`alert.attributes.name: "*<text>*"`) matches an
124/// analyzed field case-insensitively, so the local matcher must too, or a
125/// `diff`/`push` would select a different rule set than the remote read finds.
126fn search_matches(rule: &Rule, text: &str) -> bool {
127    let needle = text.to_lowercase();
128    rule.name().to_lowercase().contains(needle.as_str()) || rule.tags().contains(&text)
129}
130
131/// Resolve selectors, an optional tag, and an optional search to `rule_id`
132/// values. Returns `None` when none is given and the caller should act on every
133/// rule.
134///
135/// Check `local` before the stack. Pass an empty slice for `rules export` and
136/// `state pull`. Pass disk rules for local commands so scoped `push` can select
137/// locally added rules that the stack does not have.
138///
139/// `noun` completes command-specific refusal messages, such as "nothing to
140/// export".
141pub async fn resolve(
142    t: &Transport,
143    selectors: &[String],
144    tag: Option<&str>,
145    search: Option<&str>,
146    local: &[Rule],
147    noun: &str,
148) -> Result<Option<Vec<String>>> {
149    if selectors.is_empty() && tag.is_none() && search.is_none() {
150        return Ok(None);
151    }
152
153    // An empty or whitespace-only search matches every rule, both locally
154    // (`contains("")`) and remotely (`name: "**"`). Refuse it rather than
155    // silently widening a scoped operation to the whole corpus.
156    if let Some(text) = search
157        && text.trim().is_empty()
158    {
159        return Err(Error::new(
160            ErrorKind::Error,
161            "the --search text must not be empty or whitespace-only",
162        ));
163    }
164
165    let mut ids: Vec<String> = Vec::new();
166    for s in selectors {
167        match local_match(local, s) {
168            Some(found) => ids.push(found?),
169            None => ids.push(to_rule_id(t, s).await?),
170        }
171    }
172
173    // Track tag matches separately so a selector cannot hide an unmatched tag.
174    let mut tag_matched = false;
175    if let Some(tag) = tag {
176        for rule in local.iter().filter(|r| r.tags().contains(&tag)) {
177            tag_matched = true;
178            ids.push(rule.rule_id()?.to_string());
179        }
180
181        let filter = RuleFilter {
182            tag: Some(tag.to_string()),
183            ..Default::default()
184        };
185        for rule in rules::find_all(t, &filter).await? {
186            tag_matched = true;
187            ids.push(rule.rule_id()?.to_string());
188        }
189    }
190
191    // `--search` mirrors `--tag`: match locally, then on the stack, and union
192    // the results. The local matcher reuses the same predicate as the remote
193    // KQL so both sides narrow to the same rule set.
194    let mut search_matched = false;
195    if let Some(text) = search {
196        for rule in local.iter().filter(|r| search_matches(r, text)) {
197            search_matched = true;
198            ids.push(rule.rule_id()?.to_string());
199        }
200
201        let filter = RuleFilter {
202            search: Some(text.to_string()),
203            ..Default::default()
204        };
205        for rule in rules::find_all(t, &filter).await? {
206            search_matched = true;
207            ids.push(rule.rule_id()?.to_string());
208        }
209    }
210
211    ids.sort();
212    ids.dedup();
213
214    // Report an unmatched `--tag` even when a selector matched. A mistyped tag
215    // must not silently shrink the result.
216    if let Some(t) = tag
217        && !tag_matched
218    {
219        return Err(Error::new(
220            ErrorKind::NotFound,
221            format!("No rules matched tag '{t}'; nothing to {noun}"),
222        ));
223    }
224
225    // Same for `--search`: a mistyped text must not silently shrink the result.
226    if let Some(s) = search
227        && !search_matched
228    {
229        return Err(Error::new(
230            ErrorKind::NotFound,
231            format!("No rules matched search '{s}'; nothing to {noun}"),
232        ));
233    }
234
235    // Defensive fallback: name the selector in the refusal rather than leaving
236    // it blank.
237    if ids.is_empty() {
238        return Err(Error::new(
239            ErrorKind::NotFound,
240            format!(
241                "No rules matched the selector(s) '{}'; nothing to {noun}",
242                selectors.join("', '")
243            ),
244        ));
245    }
246
247    Ok(Some(ids))
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use serde_json::json;
254
255    fn rule(id: &str, name: &str) -> Rule {
256        Rule::from_value(json!({"rule_id": id, "name": name})).unwrap()
257    }
258
259    #[test]
260    fn a_unique_name_resolves_to_its_rule_id() {
261        let found = vec![rule("a", "Alpha"), rule("b", "Beta")];
262        assert_eq!(pick_by_name(&found, "Beta").unwrap(), "b");
263    }
264
265    #[test]
266    fn an_ambiguous_name_is_a_conflict_listing_every_candidate() {
267        let found = vec![rule("a", "Same"), rule("b", "Same")];
268        let err = pick_by_name(&found, "Same").unwrap_err();
269        assert_eq!(err.kind, ErrorKind::Conflict);
270        assert!(
271            err.message.contains('a') && err.message.contains('b'),
272            "{}",
273            err.message
274        );
275    }
276
277    #[test]
278    fn a_name_with_no_match_is_not_found() {
279        let err = pick_by_name(&[rule("a", "Alpha")], "Ghost").unwrap_err();
280        assert_eq!(err.kind, ErrorKind::NotFound);
281    }
282
283    #[test]
284    fn a_capped_candidate_page_says_so_rather_than_claiming_the_name_is_absent() {
285        // A truncated result must not report the name as absent.
286        let found: Vec<Rule> = (0..NAME_SEARCH_LIMIT)
287            .map(|i| rule(&format!("id-{i}"), "Other"))
288            .collect();
289        let err = pick_by_name_capped(&found, "Ghost", NAME_SEARCH_LIMIT as u64 + 1).unwrap_err();
290        assert_eq!(err.kind, ErrorKind::NotFound);
291        assert!(
292            err.message.contains("first 100"),
293            "the cap must be visible: {}",
294            err.message
295        );
296    }
297
298    #[test]
299    fn name_matching_is_exact_not_substring() {
300        let err = pick_by_name(&[rule("a", "Alpha Rule")], "Alpha").unwrap_err();
301        assert_eq!(err.kind, ErrorKind::NotFound);
302    }
303
304    #[test]
305    fn a_conflict_still_names_every_candidate_when_one_rule_id_is_unreadable() {
306        let readable = rule("a", "Same");
307        // `Rule::from_value` rejects non-string IDs, but transparent
308        // `Deserialize` does not, so `pick_by_name` must handle one.
309        let unreadable: Rule =
310            serde_json::from_value(json!({"rule_id": 123, "name": "Same"})).unwrap();
311        let found = vec![readable, unreadable];
312
313        let err = pick_by_name(&found, "Same").unwrap_err();
314
315        assert_eq!(err.kind, ErrorKind::Conflict);
316        assert!(err.message.contains("2 rules"), "{}", err.message);
317        assert!(
318            err.message.contains('a') && err.message.contains(UNREADABLE_RULE_ID),
319            "the count claims 2 candidates, so both must be listed: {}",
320            err.message
321        );
322    }
323
324    #[test]
325    fn a_local_rule_id_matches_without_asking_the_stack() {
326        let local = vec![rule("a", "Alpha")];
327        assert_eq!(local_match(&local, "a").unwrap().unwrap(), "a");
328    }
329
330    #[test]
331    fn a_local_name_resolves_to_its_rule_id() {
332        let local = vec![rule("a", "Alpha")];
333        assert_eq!(local_match(&local, "Alpha").unwrap().unwrap(), "a");
334    }
335
336    #[test]
337    fn an_unmatched_selector_falls_through_to_the_stack() {
338        let local = vec![rule("a", "Alpha")];
339        assert!(local_match(&local, "Ghost").is_none());
340    }
341
342    /// The server matches the analyzed `name` field case-insensitively, so the
343    /// local `--search` matcher must too, or `diff`/`push` would narrow the
344    /// local side differently from the remote read (spec 4.7).
345    #[test]
346    fn search_matches_a_different_case_name_substring() {
347        let r = rule("a", "Suspicious Process");
348        assert!(search_matches(&r, "process"));
349        assert!(search_matches(&r, "PROCESS"));
350        assert!(search_matches(&r, "suspicious"));
351        assert!(!search_matches(&r, "unrelated"));
352    }
353
354    #[test]
355    fn search_matches_an_exact_tag_but_not_a_tag_substring() {
356        let r = Rule::from_value(json!({
357            "rule_id": "a",
358            "name": "Alpha",
359            "tags": ["prod"]
360        }))
361        .unwrap();
362        assert!(search_matches(&r, "prod"));
363        assert!(!search_matches(&r, "pro"), "tags are exact, not substring");
364    }
365
366    #[test]
367    fn an_ambiguous_local_name_is_refused_naming_both_rule_ids() {
368        let local = vec![rule("a", "Same"), rule("b", "Same")];
369        let err = local_match(&local, "Same").unwrap().unwrap_err();
370        assert_eq!(err.kind, ErrorKind::Conflict);
371        assert!(
372            err.message.contains('a') && err.message.contains('b'),
373            "identity is rule_id, so both must be named: {}",
374            err.message
375        );
376    }
377}