Skip to main content

isothrum_runner/
name.rs

1//! A run's name: what the embedder calls the run (`cyberctf-flag-portal`), else the run's name.
2//! It names the run's state folder and, through `${run.name}`, the Compose project and the
3//! networks, so it holds only what every engine accepts.
4
5use std::fmt;
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use crate::Result;
10use crate::error::Error;
11
12/// The longest name: Docker's limit for a project name, minus room for a network suffix.
13const MAX_LEN: usize = 63;
14
15/// A run's name: 1 to 63 lowercase letters, digits, `-` or `_`, starting with a letter or digit
16/// (Compose's rule for a project name).
17#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct RunName(String);
19
20impl RunName {
21    /// The name, when it can name a run.
22    ///
23    /// # Errors
24    ///
25    /// Fails with `run_name` when `value` breaks that rule.
26    pub fn new(value: &str) -> Result<RunName> {
27        let mut chars = value.chars();
28        let first = chars.next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
29        let rest = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_'));
30        if first && rest && value.len() <= MAX_LEN {
31            Ok(RunName(value.to_owned()))
32        } else {
33            Err(Error::RunName { value: value.to_owned() })
34        }
35    }
36
37    /// The name.
38    #[must_use]
39    pub fn as_str(&self) -> &str {
40        &self.0
41    }
42}
43
44impl fmt::Display for RunName {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.write_str(&self.0)
47    }
48}
49
50impl Serialize for RunName {
51    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
52        s.serialize_str(&self.0)
53    }
54}
55
56impl<'de> Deserialize<'de> for RunName {
57    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<RunName, D::Error> {
58        RunName::new(&String::deserialize(d)?).map_err(serde::de::Error::custom)
59    }
60}
61
62impl std::str::FromStr for RunName {
63    type Err = Error;
64
65    fn from_str(s: &str) -> Result<RunName> {
66        RunName::new(s)
67    }
68}
69
70impl TryFrom<&str> for RunName {
71    type Error = Error;
72
73    fn try_from(value: &str) -> Result<RunName> {
74        RunName::new(value)
75    }
76}
77
78impl TryFrom<String> for RunName {
79    type Error = Error;
80
81    fn try_from(value: String) -> Result<RunName> {
82        RunName::new(&value)
83    }
84}
85
86impl AsRef<str> for RunName {
87    fn as_ref(&self) -> &str {
88        &self.0
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn a_run_name_is_a_compose_project_name() {
98        for good in ["flag-portal", "cyberctf-invoice-portal-api", "a", "lab_2", "9lives"] {
99            assert_eq!(RunName::new(good).unwrap().as_str(), good);
100        }
101        for bad in ["", "Flag", "-x", "_x", "a b", "a/b", "..", "é", &"a".repeat(64)] {
102            assert_eq!(RunName::new(bad).unwrap_err().code(), crate::ErrorCode::RunName, "{bad}");
103        }
104    }
105
106    #[test]
107    fn a_run_name_reads_back_checked() {
108        let name: RunName = serde_json::from_str("\"web-1\"").unwrap();
109        assert_eq!(serde_json::to_string(&name).unwrap(), "\"web-1\"");
110        assert!(serde_json::from_str::<RunName>("\"../etc\"").is_err());
111    }
112}