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