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