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 get_if_exists(t, selector).await? {
77        Some(rule) => Ok(rule.rule_id()?.to_string()),
78        None => by_name(t, selector).await,
79    }
80}
81
82/// Resolve a selector like [`to_rule_id`] and return the stored rule too.
83///
84/// Reuse the first read only when it echoes the selector. Kibana matches
85/// `rule_id` through KQL, which decodes escapes, so `r\\n` can return the rule
86/// stored as `r\n`; that rule must be read again by its own `rule_id`.
87pub async fn to_rule(t: &Transport, selector: &str) -> Result<(String, Rule)> {
88    let rule_id = match get_if_exists(t, selector).await? {
89        Some(rule) if rule.rule_id()? == selector => return Ok((selector.to_string(), rule)),
90        Some(rule) => rule.rule_id()?.to_string(),
91        None => by_name(t, selector).await?,
92    };
93    let rule = rules::get(t, &rule_id).await?;
94    Ok((rule_id, rule))
95}
96
97async fn get_if_exists(t: &Transport, rule_id: &str) -> Result<Option<Rule>> {
98    match rules::get(t, rule_id).await {
99        Ok(rule) => Ok(Some(rule)),
100        Err(e) if e.kind == ErrorKind::NotFound => Ok(None),
101        Err(e) => Err(e),
102    }
103}
104
105async fn by_name(t: &Transport, selector: &str) -> Result<String> {
106    let filter = RuleFilter {
107        name: Some(selector.to_string()),
108        ..Default::default()
109    };
110    let (candidates, total) = rules::find_page(t, &filter, 1, NAME_SEARCH_LIMIT).await?;
111    pick_by_name_capped(&candidates, selector, total)
112}
113
114/// Match a selector against local rules before querying the stack.
115///
116/// `None` means no local match, so the caller queries the stack.
117/// `Some(Err(..))` indicates an ambiguous local name.
118fn local_match(local: &[Rule], selector: &str) -> Option<Result<String>> {
119    if local.iter().any(|r| r.rule_id().ok() == Some(selector)) {
120        return Some(Ok(selector.to_string()));
121    }
122
123    let named: Vec<&Rule> = local.iter().filter(|r| r.name() == selector).collect();
124    match named.len() {
125        0 => None,
126        1 => Some(named[0].rule_id().map(str::to_string)),
127        _ => {
128            let ids: Vec<&str> = named
129                .iter()
130                .map(|r| r.rule_id().unwrap_or(UNREADABLE_RULE_ID))
131                .collect();
132            Some(Err(Error::new(
133                ErrorKind::Conflict,
134                format!(
135                    "{} local rules are named '{selector}'. Select one by rule_id: {}",
136                    named.len(),
137                    ids.join(", ")
138                ),
139            )))
140        }
141    }
142}
143
144/// Whether `rule` matches a `--search` text: a case-insensitive name substring
145/// or an exact tag.
146///
147/// The server-side KQL (`alert.attributes.name: "*<text>*"`) matches an
148/// analyzed field case-insensitively, so the local matcher must too, or a
149/// `diff`/`push` would select a different rule set than the remote read finds.
150fn search_matches(rule: &Rule, text: &str) -> bool {
151    let needle = text.to_lowercase();
152    rule.name().to_lowercase().contains(needle.as_str()) || rule.tags().contains(&text)
153}
154
155/// Resolve selectors, an optional tag, and an optional search to `rule_id`
156/// values. Returns `None` when none is given and the caller should act on every
157/// rule.
158///
159/// Check `local` before the stack. Pass an empty slice for `rules export` and
160/// `state pull`. Pass disk rules for local commands so scoped `push` can select
161/// locally added rules that the stack does not have.
162///
163/// `noun` completes command-specific refusal messages, such as "nothing to
164/// export".
165pub async fn resolve(
166    t: &Transport,
167    selectors: &[String],
168    tag: Option<&str>,
169    search: Option<&str>,
170    local: &[Rule],
171    noun: &str,
172) -> Result<Option<Vec<String>>> {
173    if selectors.is_empty() && tag.is_none() && search.is_none() {
174        return Ok(None);
175    }
176
177    // An empty or whitespace-only search matches every rule, both locally
178    // (`contains("")`) and remotely (`name: "**"`). Refuse it rather than
179    // silently widening a scoped operation to the whole corpus.
180    if let Some(text) = search
181        && text.trim().is_empty()
182    {
183        return Err(Error::new(
184            ErrorKind::Error,
185            "the --search text must not be empty or whitespace-only",
186        ));
187    }
188
189    let mut ids: Vec<String> = Vec::new();
190    for s in selectors {
191        match local_match(local, s) {
192            Some(found) => ids.push(found?),
193            None => ids.push(to_rule_id(t, s).await?),
194        }
195    }
196
197    // Track tag matches separately so a selector cannot hide an unmatched tag.
198    let mut tag_matched = false;
199    if let Some(tag) = tag {
200        for rule in local.iter().filter(|r| r.tags().contains(&tag)) {
201            tag_matched = true;
202            ids.push(rule.rule_id()?.to_string());
203        }
204
205        let filter = RuleFilter {
206            tag: Some(tag.to_string()),
207            ..Default::default()
208        };
209        for rule in rules::find_all(t, &filter).await? {
210            tag_matched = true;
211            ids.push(rule.rule_id()?.to_string());
212        }
213    }
214
215    // `--search` mirrors `--tag`: match locally, then on the stack, and union
216    // the results. The local matcher reuses the same predicate as the remote
217    // KQL so both sides narrow to the same rule set.
218    let mut search_matched = false;
219    if let Some(text) = search {
220        for rule in local.iter().filter(|r| search_matches(r, text)) {
221            search_matched = true;
222            ids.push(rule.rule_id()?.to_string());
223        }
224
225        let filter = RuleFilter {
226            search: Some(text.to_string()),
227            ..Default::default()
228        };
229        for rule in rules::find_all(t, &filter).await? {
230            search_matched = true;
231            ids.push(rule.rule_id()?.to_string());
232        }
233    }
234
235    ids.sort();
236    ids.dedup();
237
238    // Report an unmatched `--tag` even when a selector matched. A mistyped tag
239    // must not silently shrink the result.
240    if let Some(t) = tag
241        && !tag_matched
242    {
243        return Err(Error::new(
244            ErrorKind::NotFound,
245            format!("No rules matched tag '{t}'; nothing to {noun}"),
246        ));
247    }
248
249    // Same for `--search`: a mistyped text must not silently shrink the result.
250    if let Some(s) = search
251        && !search_matched
252    {
253        return Err(Error::new(
254            ErrorKind::NotFound,
255            format!("No rules matched search '{s}'; nothing to {noun}"),
256        ));
257    }
258
259    // Defensive fallback: name the selector in the refusal rather than leaving
260    // it blank.
261    if ids.is_empty() {
262        return Err(Error::new(
263            ErrorKind::NotFound,
264            format!(
265                "No rules matched the selector(s) '{}'; nothing to {noun}",
266                selectors.join("', '")
267            ),
268        ));
269    }
270
271    Ok(Some(ids))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use serde_json::json;
278
279    fn rule(id: &str, name: &str) -> Rule {
280        Rule::from_value(json!({"rule_id": id, "name": name})).unwrap()
281    }
282
283    #[test]
284    fn a_unique_name_resolves_to_its_rule_id() {
285        let found = vec![rule("a", "Alpha"), rule("b", "Beta")];
286        assert_eq!(pick_by_name(&found, "Beta").unwrap(), "b");
287    }
288
289    #[test]
290    fn an_ambiguous_name_is_a_conflict_listing_every_candidate() {
291        let found = vec![rule("a", "Same"), rule("b", "Same")];
292        let err = pick_by_name(&found, "Same").unwrap_err();
293        assert_eq!(err.kind, ErrorKind::Conflict);
294        assert!(
295            err.message.contains('a') && err.message.contains('b'),
296            "{}",
297            err.message
298        );
299    }
300
301    #[test]
302    fn a_name_with_no_match_is_not_found() {
303        let err = pick_by_name(&[rule("a", "Alpha")], "Ghost").unwrap_err();
304        assert_eq!(err.kind, ErrorKind::NotFound);
305    }
306
307    #[test]
308    fn a_capped_candidate_page_says_so_rather_than_claiming_the_name_is_absent() {
309        // A truncated result must not report the name as absent.
310        let found: Vec<Rule> = (0..NAME_SEARCH_LIMIT)
311            .map(|i| rule(&format!("id-{i}"), "Other"))
312            .collect();
313        let err = pick_by_name_capped(&found, "Ghost", NAME_SEARCH_LIMIT as u64 + 1).unwrap_err();
314        assert_eq!(err.kind, ErrorKind::NotFound);
315        assert!(
316            err.message.contains("first 100"),
317            "the cap must be visible: {}",
318            err.message
319        );
320    }
321
322    #[test]
323    fn name_matching_is_exact_not_substring() {
324        let err = pick_by_name(&[rule("a", "Alpha Rule")], "Alpha").unwrap_err();
325        assert_eq!(err.kind, ErrorKind::NotFound);
326    }
327
328    #[test]
329    fn a_conflict_still_names_every_candidate_when_one_rule_id_is_unreadable() {
330        let readable = rule("a", "Same");
331        // `Rule::from_value` rejects non-string IDs, but transparent
332        // `Deserialize` does not, so `pick_by_name` must handle one.
333        let unreadable: Rule =
334            serde_json::from_value(json!({"rule_id": 123, "name": "Same"})).unwrap();
335        let found = vec![readable, unreadable];
336
337        let err = pick_by_name(&found, "Same").unwrap_err();
338
339        assert_eq!(err.kind, ErrorKind::Conflict);
340        assert!(err.message.contains("2 rules"), "{}", err.message);
341        assert!(
342            err.message.contains('a') && err.message.contains(UNREADABLE_RULE_ID),
343            "the count claims 2 candidates, so both must be listed: {}",
344            err.message
345        );
346    }
347
348    #[test]
349    fn a_local_rule_id_matches_without_asking_the_stack() {
350        let local = vec![rule("a", "Alpha")];
351        assert_eq!(local_match(&local, "a").unwrap().unwrap(), "a");
352    }
353
354    #[test]
355    fn a_local_name_resolves_to_its_rule_id() {
356        let local = vec![rule("a", "Alpha")];
357        assert_eq!(local_match(&local, "Alpha").unwrap().unwrap(), "a");
358    }
359
360    #[test]
361    fn an_unmatched_selector_falls_through_to_the_stack() {
362        let local = vec![rule("a", "Alpha")];
363        assert!(local_match(&local, "Ghost").is_none());
364    }
365
366    /// The server matches the analyzed `name` field case-insensitively, so the
367    /// local `--search` matcher must too, or `diff`/`push` would narrow the
368    /// local side differently from the remote read (spec 4.7).
369    #[test]
370    fn search_matches_a_different_case_name_substring() {
371        let r = rule("a", "Suspicious Process");
372        assert!(search_matches(&r, "process"));
373        assert!(search_matches(&r, "PROCESS"));
374        assert!(search_matches(&r, "suspicious"));
375        assert!(!search_matches(&r, "unrelated"));
376    }
377
378    #[test]
379    fn search_matches_an_exact_tag_but_not_a_tag_substring() {
380        let r = Rule::from_value(json!({
381            "rule_id": "a",
382            "name": "Alpha",
383            "tags": ["prod"]
384        }))
385        .unwrap();
386        assert!(search_matches(&r, "prod"));
387        assert!(!search_matches(&r, "pro"), "tags are exact, not substring");
388    }
389
390    #[test]
391    fn an_ambiguous_local_name_is_refused_naming_both_rule_ids() {
392        let local = vec![rule("a", "Same"), rule("b", "Same")];
393        let err = local_match(&local, "Same").unwrap().unwrap_err();
394        assert_eq!(err.kind, ErrorKind::Conflict);
395        assert!(
396            err.message.contains('a') && err.message.contains('b'),
397            "identity is rule_id, so both must be named: {}",
398            err.message
399        );
400    }
401}