1use core::fmt;
7
8#[derive(Debug, Clone)]
10pub enum ProcessSelector {
11 All,
13 Id(u32),
15 Name(String),
17 Regex(regex::Regex),
19 Fold(String),
21 Instance {
23 name: String,
25 slot: u32,
27 },
28}
29
30fn is_glob(input: &str) -> bool {
40 input.contains(['*', '?', '[', '{'])
41}
42
43fn 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 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 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 #[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 #[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 Self::Instance { name: want, slot } => want == name && instance == Some(*slot),
153 }
154 }
155}
156
157#[non_exhaustive]
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum SelectorError {
167 Empty,
169 EmptyFold,
171 BadRegex(String),
173 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 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 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 #[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 #[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 #[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 assert!(!ProcessSelector::parse("/^bark$/").unwrap().is_exact());
386 }
387
388 #[test]
389 fn a_name_that_looks_numeric_is_an_id() {
390 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 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 assert!(matches!(
470 ProcessSelector::parse("web:two").expect("parses"),
471 ProcessSelector::Name(_)
472 ));
473 assert!(matches!(
475 ProcessSelector::parse("web*:2").expect("parses"),
476 ProcessSelector::Regex(_)
477 ));
478 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 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}