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