Skip to main content

shep_core/
selector.rs

1//! Target selection: one parse for every CLI verb and RPC filter
2//!
3//! Precedence: `all` > `fold:<name>` > `/regex/` > all-digits id > glob >
4//! `name:slot` > name.
5
6use core::fmt;
7
8/// A parsed process selector (spec §9: name, id, `all`, `/regex/`, `fold:`)
9#[derive(Debug, Clone)]
10pub enum ProcessSelector {
11    /// Every sheep in the flock
12    All,
13    /// By numeric id
14    Id(u32),
15    /// By exact name
16    Name(String),
17    /// By regex over names (slash-delimited on the CLI)
18    Regex(regex::Regex),
19    /// Every sheep in a fold
20    Fold(String),
21    /// One instance of one app, written `name:slot` on the CLI
22    Instance {
23        /// The app name, which cannot itself contain a colon
24        name: String,
25        /// The instance slot, counting from 0
26        slot: u32,
27    },
28}
29
30/// Whether `input` carries a glob metacharacter, and so was meant as a
31/// pattern rather than as a name.
32///
33/// Checked after `all`, `fold:`, `/regex/` and the id form, so none of those
34/// can be shadowed by a name that happens to contain one of these.
35///
36/// A name with no metacharacter stays an exact name: `web.1` is the sheep
37/// called `web.1`, not a pattern where `.` means "any character". Ordinary
38/// punctuation in a name means nothing.
39fn is_glob(input: &str) -> bool {
40    input.contains(['*', '?', '[', '{'])
41}
42
43/// Compiles a glob and hands back its regex source.
44///
45/// `globset`'s pattern is already anchored, so `web-*` matches `web-api`
46/// and not `my-web-api`. Strips the `(?-u)` prefix, since `globset`
47/// compiles for bytes and `regex::Regex` refuses that for a `String`.
48/// Returns [`ProcessSelector::Regex`] rather than its own variant: a new
49/// variant on `SelectorSpec` is a protocol change an older daemon could not
50/// deserialize.
51///
52/// # Errors
53///
54/// - [`SelectorError::BadGlob`]: the pattern is not a valid glob.
55fn glob_to_regex(input: &str) -> Result<String, SelectorError> {
56    let glob = globset::Glob::new(input).map_err(|e| SelectorError::BadGlob(e.to_string()))?;
57    let source = glob.regex().to_string();
58    Ok(source
59        .strip_prefix("(?-u)")
60        .map_or(source.clone(), ToString::to_string))
61}
62
63impl ProcessSelector {
64    /// Parses CLI selector syntax
65    ///
66    /// # Errors
67    ///
68    /// - [`SelectorError::Empty`]: empty input.
69    /// - [`SelectorError::EmptyFold`]: `fold:` with no name.
70    /// - [`SelectorError::BadRegex`]: `/re/` body rejected by the regex
71    ///   crate (carries its message).
72    /// - [`SelectorError::BadGlob`]: a pattern carrying `*`, `?`, `[` or `{`
73    ///   was rejected by `globset` (carries its message).
74    pub fn parse(input: &str) -> Result<Self, SelectorError> {
75        if input.is_empty() {
76            return Err(SelectorError::Empty);
77        }
78        if input == "all" {
79            return Ok(Self::All);
80        }
81        if let Some(fold) = input.strip_prefix("fold:") {
82            if fold.is_empty() {
83                return Err(SelectorError::EmptyFold);
84            }
85            return Ok(Self::Fold(fold.to_string()));
86        }
87        if input.len() >= 2 && input.starts_with('/') && input.ends_with('/') {
88            let body = &input[1..input.len() - 1];
89            return regex::Regex::new(body)
90                .map(Self::Regex)
91                .map_err(|e| SelectorError::BadRegex(e.to_string()));
92        }
93        if input.bytes().all(|b| b.is_ascii_digit())
94            && let Ok(id) = input.parse()
95        {
96            return Ok(Self::Id(id));
97        }
98        if is_glob(input) {
99            return glob_to_regex(input)
100                .and_then(|re| {
101                    regex::Regex::new(&re).map_err(|e| SelectorError::BadRegex(e.to_string()))
102                })
103                .map(Self::Regex);
104        }
105        // Last, so every earlier form wins. A name cannot contain a colon
106        // (`config::normalize` refuses one), so splitting on the last one
107        // cannot cut a name in half.
108        if let Some((name, slot)) = input.rsplit_once(':')
109            && !name.is_empty()
110            && !slot.is_empty()
111            && slot.bytes().all(|b| b.is_ascii_digit())
112            && let Ok(slot) = slot.parse()
113        {
114            return Ok(Self::Instance {
115                name: name.to_string(),
116                slot,
117            });
118        }
119
120        Ok(Self::Name(input.to_string()))
121    }
122
123    /// Whether this selector names ONE entry the caller already knew of, by
124    /// its name or its id, rather than sweeping whatever matches.
125    ///
126    /// The distinction a dog turns on: a dog is a process an operator
127    /// installed, not a member of the flock `all` means, so a wildcard must
128    /// pass it by while `shep restart metrics` still reaches it.
129    /// [`Self::Regex`] and [`Self::Fold`] are wildcards even when they
130    /// happen to match one entry: the operator did not name it.
131    #[must_use]
132    pub const fn is_exact(&self) -> bool {
133        match self {
134            Self::Id(_) | Self::Name(_) | Self::Instance { .. } => true,
135            Self::All | Self::Regex(_) | Self::Fold(_) => false,
136        }
137    }
138
139    /// Tests one sheep against this selector
140    #[must_use]
141    pub fn matches(&self, name: &str, id: u32, fold: Option<&str>, instance: Option<u32>) -> bool {
142        match self {
143            Self::All => true,
144            Self::Id(want) => *want == id,
145            Self::Name(want) => want == name,
146            Self::Regex(re) => re.is_match(name),
147            Self::Fold(want) => fold == Some(want.as_str()),
148            // `None` means the peer daemon predates the slot field, so this
149            // row cannot be shown to be the one asked for. Refusing to match
150            // is the safe direction: a restart reaches nothing rather than
151            // reaching every instance of the name.
152            Self::Instance { name: want, slot } => want == name && instance == Some(*slot),
153        }
154    }
155}
156
157/// Error type returned from [`ProcessSelector::parse`]
158///
159/// `#[non_exhaustive]`: a future selector kind with its own malformed-value
160/// class, such as a `status:` filter rejecting an unknown state, needs a new
161/// variant rather than stretching [`Self::BadRegex`] to mean something it
162/// does not, and shep-core is a published library an out-of-tree matcher
163/// should not break for.
164#[non_exhaustive]
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum SelectorError {
167    /// The selector string was empty
168    Empty,
169    /// `fold:` with no fold name after the colon
170    EmptyFold,
171    /// The `/regex/` body failed to compile (carries the regex message)
172    BadRegex(String),
173    /// A glob pattern was rejected by `globset` (carries its message)
174    BadGlob(String),
175}
176
177impl fmt::Display for SelectorError {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        match self {
180            Self::Empty => f.write_str("selector is empty"),
181            Self::EmptyFold => f.write_str("fold selector is missing a name"),
182            Self::BadRegex(m) => write!(f, "invalid selector regex: {m}"),
183            Self::BadGlob(m) => write!(f, "invalid selector glob: {m}"),
184        }
185    }
186}
187
188impl core::error::Error for SelectorError {}
189
190impl std::convert::TryFrom<crate::protocol::SelectorSpec> for ProcessSelector {
191    type Error = SelectorError;
192
193    /// Compiles a wire selector into a matchable one
194    ///
195    /// # Errors
196    ///
197    /// - [`SelectorError::BadRegex`]: the peer-supplied pattern fails to
198    ///   compile or exceeds the 1 MiB compiled-size bound.
199    fn try_from(spec: crate::protocol::SelectorSpec) -> Result<Self, Self::Error> {
200        use crate::protocol::SelectorSpec;
201        Ok(match spec {
202            SelectorSpec::All => Self::All,
203            SelectorSpec::Id(id) => Self::Id(id),
204            SelectorSpec::Name(name) => Self::Name(name),
205            SelectorSpec::Fold(fold) => Self::Fold(fold),
206            SelectorSpec::Instance { name, slot } => Self::Instance { name, slot },
207            SelectorSpec::Regex(src) => Self::Regex(
208                // Peer-supplied pattern: bound compiled-program memory.
209                regex::RegexBuilder::new(&src)
210                    .size_limit(1 << 20)
211                    .build()
212                    .map_err(|e| SelectorError::BadRegex(e.to_string()))?,
213            ),
214        })
215    }
216}
217
218impl From<&ProcessSelector> for crate::protocol::SelectorSpec {
219    fn from(sel: &ProcessSelector) -> Self {
220        use crate::protocol::SelectorSpec;
221        match sel {
222            ProcessSelector::All => SelectorSpec::All,
223            ProcessSelector::Id(id) => SelectorSpec::Id(*id),
224            ProcessSelector::Name(name) => SelectorSpec::Name(name.clone()),
225            ProcessSelector::Regex(re) => SelectorSpec::Regex(re.as_str().to_string()),
226            ProcessSelector::Fold(fold) => SelectorSpec::Fold(fold.clone()),
227            ProcessSelector::Instance { name, slot } => SelectorSpec::Instance {
228                name: name.clone(),
229                slot: *slot,
230            },
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn a_name_without_a_metacharacter_is_still_an_exact_name() {
241        for plain in ["zeus-auth", "web.1", "api_v2", "a-b-c"] {
242            let parsed = ProcessSelector::parse(plain).unwrap();
243            assert!(
244                matches!(&parsed, ProcessSelector::Name(name) if name == plain),
245                "{plain} carries no glob metacharacter and is a name, got {parsed:?}"
246            );
247        }
248    }
249
250    #[test]
251    fn a_glob_matches_by_prefix_and_not_by_substring() {
252        let ProcessSelector::Regex(re) = ProcessSelector::parse("zeus-*").unwrap() else {
253            panic!("a pattern with `*` is compiled to a regex");
254        };
255        assert!(re.is_match("zeus-auth"));
256        assert!(re.is_match("zeus-create"));
257        assert!(!re.is_match("my-zeus-auth"), "anchored: no substring match");
258        assert!(!re.is_match("reactmap"));
259    }
260
261    /// Every metacharacter the `is_glob` gate names has to actually work,
262    /// or the gate is claiming support it does not have.
263    #[test]
264    fn each_glob_metacharacter_compiles_and_matches() {
265        let cases = [
266            ("*api*", "my-api-thing", "web"),
267            ("zeus-?", "zeus-1", "zeus-auth"),
268            ("zeus-[ab]*", "zeus-auth", "zeus-create"),
269            ("{web,api}", "api", "worker"),
270        ];
271        for (pattern, hit, miss) in cases {
272            let ProcessSelector::Regex(re) = ProcessSelector::parse(pattern).unwrap() else {
273                panic!("{pattern} must compile to a regex");
274            };
275            assert!(re.is_match(hit), "{pattern} must match {hit}");
276            assert!(!re.is_match(miss), "{pattern} must not match {miss}");
277        }
278    }
279
280    #[test]
281    fn the_earlier_forms_are_not_shadowed_by_the_glob_gate() {
282        assert!(matches!(
283            ProcessSelector::parse("all").unwrap(),
284            ProcessSelector::All
285        ));
286        let fold = ProcessSelector::parse("fold:back*end").unwrap();
287        assert!(
288            matches!(&fold, ProcessSelector::Fold(name) if name == "back*end"),
289            "a fold name may contain a metacharacter and is still a fold, got {fold:?}"
290        );
291        let ProcessSelector::Regex(re) = ProcessSelector::parse("/^zeus-/").unwrap() else {
292            panic!("an explicit regex stays a regex");
293        };
294        assert!(re.is_match("zeus-auth"));
295    }
296
297    /// A glob `globset` refuses is reported as such rather than silently
298    /// becoming a name that can never match.
299    #[test]
300    fn an_unparseable_glob_is_refused() {
301        let err = ProcessSelector::parse("zeus-[").expect_err("an unclosed class is not a glob");
302        assert!(
303            matches!(err, SelectorError::BadGlob(_)),
304            "expected BadGlob, got {err:?}"
305        );
306        assert!(err.to_string().contains("glob"), "{err}");
307    }
308
309    #[test]
310    fn parse_rules() {
311        assert!(matches!(
312            ProcessSelector::parse("all").unwrap(),
313            ProcessSelector::All
314        ));
315        assert!(matches!(
316            ProcessSelector::parse("3").unwrap(),
317            ProcessSelector::Id(3)
318        ));
319        assert!(matches!(
320            ProcessSelector::parse("web").unwrap(),
321            ProcessSelector::Name(n) if n == "web"
322        ));
323        assert!(matches!(
324            ProcessSelector::parse("/^w/").unwrap(),
325            ProcessSelector::Regex(_)
326        ));
327        assert!(matches!(
328            ProcessSelector::parse("fold:backend").unwrap(),
329            ProcessSelector::Fold(fname) if fname == "backend"
330        ));
331    }
332
333    #[test]
334    fn parse_errors() {
335        assert_eq!(
336            ProcessSelector::parse("").unwrap_err(),
337            SelectorError::Empty
338        );
339        assert_eq!(
340            ProcessSelector::parse("fold:").unwrap_err(),
341            SelectorError::EmptyFold
342        );
343        assert!(matches!(
344            ProcessSelector::parse("/((/").unwrap_err(),
345            SelectorError::BadRegex(_)
346        ));
347    }
348
349    #[test]
350    fn matching() {
351        let by_name = ProcessSelector::parse("web").unwrap();
352        assert!(by_name.matches("web", 0, None, None));
353        assert!(!by_name.matches("worker", 0, None, None));
354
355        let by_regex = ProcessSelector::parse("/^w/").unwrap();
356        assert!(by_regex.matches("worker", 9, None, None));
357        assert!(!by_regex.matches("api", 9, None, None));
358
359        let by_fold = ProcessSelector::parse("fold:backend").unwrap();
360        assert!(by_fold.matches("anything", 0, Some("backend"), None));
361        assert!(!by_fold.matches("anything", 0, None, None));
362
363        assert!(
364            ProcessSelector::parse("all")
365                .unwrap()
366                .matches("x", 42, None, None)
367        );
368        assert!(
369            ProcessSelector::parse("42")
370                .unwrap()
371                .matches("x", 42, None, None)
372        );
373    }
374
375    /// Either mistake makes `shep reload /^web/` sweep up a dog, invisible
376    /// until a flock happens to run one whose name the pattern matches.
377    #[test]
378    fn only_a_name_or_an_id_names_one_entry_the_caller_knew_of() {
379        assert!(ProcessSelector::Name("bark".into()).is_exact());
380        assert!(ProcessSelector::Id(4).is_exact());
381        assert!(!ProcessSelector::All.is_exact());
382        assert!(!ProcessSelector::Fold("api".into()).is_exact());
383        // Built through the real parser: a `Regex` is a wildcard even when
384        // its pattern is a literal that can only ever match one name.
385        assert!(!ProcessSelector::parse("/^bark$/").unwrap().is_exact());
386    }
387
388    #[test]
389    fn a_name_that_looks_numeric_is_an_id() {
390        // Documented precedence (spec §9): digits select by id. A sheep
391        // literally named "42" must be selected by /^42$/ or renamed.
392        assert!(matches!(
393            ProcessSelector::parse("42").unwrap(),
394            ProcessSelector::Id(42)
395        ));
396    }
397
398    #[test]
399    fn selector_spec_bridges() {
400        use crate::protocol::SelectorSpec;
401        let sel: ProcessSelector = SelectorSpec::Regex("^w".to_string()).try_into().unwrap();
402        assert!(sel.matches("web", 1, None, None));
403        assert_eq!(
404            SelectorSpec::from(&sel),
405            SelectorSpec::Regex("^w".to_string())
406        );
407        for spec in [
408            SelectorSpec::All,
409            SelectorSpec::Id(3),
410            SelectorSpec::Name("web".to_string()),
411            SelectorSpec::Fold("backend".to_string()),
412            SelectorSpec::Instance {
413                name: "web".to_string(),
414                slot: 2,
415            },
416        ] {
417            let sel: ProcessSelector = spec.clone().try_into().unwrap();
418            assert_eq!(SelectorSpec::from(&sel), spec);
419        }
420    }
421
422    #[test]
423    fn selector_spec_bad_regex_is_typed_error() {
424        use crate::protocol::SelectorSpec;
425        assert!(matches!(
426            ProcessSelector::try_from(SelectorSpec::Regex("((".to_string())).unwrap_err(),
427            SelectorError::BadRegex(_)
428        ));
429    }
430
431    #[test]
432    fn an_instance_form_parses_and_matches_only_its_slot() {
433        let sel = ProcessSelector::parse("web:2").expect("parses");
434        assert!(matches!(
435            &sel,
436            ProcessSelector::Instance { name, slot } if name == "web" && *slot == 2
437        ));
438        assert!(sel.matches("web", 7, None, Some(2)));
439        assert!(!sel.matches("web", 7, None, Some(1)));
440        assert!(!sel.matches("api", 7, None, Some(2)));
441        assert!(
442            !sel.matches("web", 7, None, None),
443            "an older daemon's row carries no slot, so it cannot be the one asked for"
444        );
445    }
446
447    #[test]
448    fn an_instance_selector_names_one_entry_so_it_is_exact() {
449        // The dog rule: an operator who named it reaches it, a wildcard does not.
450        assert!(
451            ProcessSelector::parse("metrics:0")
452                .expect("parses")
453                .is_exact()
454        );
455    }
456
457    #[test]
458    fn the_colon_forms_do_not_shadow_each_other() {
459        assert!(matches!(
460            ProcessSelector::parse("fold:web").expect("parses"),
461            ProcessSelector::Fold(_)
462        ));
463        assert!(matches!(
464            ProcessSelector::parse("web:2").expect("parses"),
465            ProcessSelector::Instance { .. }
466        ));
467        // A trailing segment that is not a number is not a slot. Names cannot
468        // hold a colon any more, so this is a name that will simply match nothing.
469        assert!(matches!(
470            ProcessSelector::parse("web:two").expect("parses"),
471            ProcessSelector::Name(_)
472        ));
473        // A glob is still a glob: the glob test runs first.
474        assert!(matches!(
475            ProcessSelector::parse("web*:2").expect("parses"),
476            ProcessSelector::Regex(_)
477        ));
478        // An id is still an id.
479        assert!(matches!(
480            ProcessSelector::parse("11").expect("parses"),
481            ProcessSelector::Id(11)
482        ));
483    }
484
485    #[test]
486    fn an_instance_selector_round_trips_through_the_wire_form() {
487        let sel = ProcessSelector::parse("web:2").expect("parses");
488        let spec = crate::protocol::SelectorSpec::from(&sel);
489        assert_eq!(
490            spec,
491            crate::protocol::SelectorSpec::Instance {
492                name: "web".to_string(),
493                slot: 2
494            }
495        );
496        let back = ProcessSelector::try_from(spec).expect("converts back");
497        assert!(matches!(back, ProcessSelector::Instance { .. }));
498    }
499
500    #[test]
501    fn selector_spec_oversized_regex_is_rejected() {
502        // Peer-supplied pattern: size_limit bounds compiled-program memory.
503        // The pattern (a|b|...)^N where N is the number of alternations
504        // repeated many times generates a huge compiled regex that exceeds
505        // the 1 MiB limit: many alternations * repetition factor.
506        use crate::protocol::SelectorSpec;
507        let huge = format!("(a{}){{10000}}", "|b".repeat(100_000));
508        assert!(ProcessSelector::try_from(SelectorSpec::Regex(huge)).is_err());
509    }
510}