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 {
41 input.contains(['*', '?', '[', '{'])
42}
43
44fn 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 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 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 #[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 #[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 Self::Instance { name: want, slot } => want == name && instance == Some(*slot),
171 }
172 }
173}
174
175#[non_exhaustive]
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum SelectorError {
187 Empty,
189 EmptyFold,
191 BadRegex(String),
193 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 assert!(!ProcessSelector::parse("/^bark$/").unwrap().is_exact());
414 }
415
416 #[test]
417 fn a_name_that_looks_numeric_is_an_id() {
418 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 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 assert!(matches!(
498 ProcessSelector::parse("web:two").expect("parses"),
499 ProcessSelector::Name(_)
500 ));
501 assert!(matches!(
503 ProcessSelector::parse("web*:2").expect("parses"),
504 ProcessSelector::Regex(_)
505 ));
506 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 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}