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