Skip to main content

torrust_tracker_deployer_lib/domain/
profile_name.rs

1//! LXD profile name validation and management
2//!
3//! This module provides the `ProfileName` type which ensures LXD profile names
4//! follow the strict naming requirements imposed by LXD for security and
5//! compatibility reasons.
6//!
7//! ## Naming Requirements
8//!
9//! - Length: 1-63 characters
10//! - Characters: ASCII letters, numbers, and dashes only
11//! - Cannot start with digit or dash
12//! - Cannot end with dash
13//!
14//! These restrictions ensure compatibility with DNS records, file systems,
15//! security profiles, and host names across different environments.
16
17use std::fmt;
18use std::str::FromStr;
19
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23/// Errors that can occur during profile name validation
24#[derive(Debug, Error, PartialEq)]
25pub enum ProfileNameError {
26    #[error("Profile name cannot be empty")]
27    Empty,
28
29    #[error("Profile name must be 63 characters or less, got {length} characters")]
30    TooLong { length: usize },
31
32    #[error("Profile name must not start with a digit or dash")]
33    InvalidFirstCharacter,
34
35    #[error("Profile name must not end with a dash")]
36    InvalidLastCharacter,
37
38    #[error("Profile name must contain only ASCII letters, numbers, and dashes")]
39    InvalidCharacters,
40}
41
42/// A validated LXD profile name following LXD naming requirements.
43///
44/// Valid profile names must fulfill the following requirements:
45/// - The name must be between 1 and 63 characters long
46/// - The name must contain only letters, numbers and dashes from the ASCII table
47/// - The name must not start with a digit or a dash
48/// - The name must not end with a dash
49///
50/// These requirements ensure that the profile name can be used in DNS records,
51/// on the file system, in various security profiles and as the host name.
52///
53/// # Examples
54///
55/// ```rust
56/// use torrust_tracker_deployer_lib::domain::ProfileName;
57///
58/// // Valid profile names - accepts both &str and String
59/// let profile1 = ProfileName::new("test-profile")?;
60/// let profile2 = ProfileName::new("web-server-profile".to_string())?;
61/// let profile3 = ProfileName::new(format!("app-{}-profile", "prod"))?;
62///
63/// // Invalid profile names
64/// assert!(ProfileName::new("").is_err());
65/// assert!(ProfileName::new("test-").is_err());
66/// assert!(ProfileName::new("-test").is_err());
67/// assert!(ProfileName::new("1test").is_err());
68/// # Ok::<(), Box<dyn std::error::Error>>(())
69/// ```
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(try_from = "String")]
72pub struct ProfileName(String);
73
74impl ProfileName {
75    /// Creates a new `ProfileName` from a string if it's valid.
76    ///
77    /// # Arguments
78    ///
79    /// * `name` - The profile name to validate (accepts `&str`, `String`, or anything that implements `Into<String>`)
80    ///
81    /// # Returns
82    ///
83    /// * `Ok(ProfileName)` - If the name is valid
84    /// * `Err(ProfileNameError)` - If the name violates LXD naming requirements
85    ///
86    /// # Errors
87    ///
88    /// This function will return an error if the name violates any LXD naming requirements:
89    /// * Empty name
90    /// * Name longer than 63 characters
91    /// * Name contains non-ASCII letters, numbers, or dashes
92    /// * Name starts with a digit or dash
93    /// * Name ends with a dash
94    ///
95    /// # Examples
96    ///
97    /// ```rust
98    /// use torrust_tracker_deployer_lib::domain::ProfileName;
99    ///
100    /// // Valid names - accepts both &str and String
101    /// let name1 = ProfileName::new("test-profile")?;
102    /// let name2 = ProfileName::new("web-01-profile".to_string())?;
103    /// let name3 = ProfileName::new(format!("app-{}-profile", "prod"))?;
104    ///
105    /// // Invalid names
106    /// assert!(ProfileName::new("").is_err());
107    /// assert!(ProfileName::new("test-").is_err());
108    /// assert!(ProfileName::new("-test").is_err());
109    /// assert!(ProfileName::new("1test").is_err());
110    /// # Ok::<(), Box<dyn std::error::Error>>(())
111    /// ```
112    pub fn new<S: Into<String>>(name: S) -> Result<Self, ProfileNameError> {
113        let name = name.into();
114        Self::validate(&name)?;
115        Ok(Self(name))
116    }
117
118    /// Returns the profile name as a string slice.
119    #[must_use]
120    pub fn as_str(&self) -> &str {
121        &self.0
122    }
123
124    /// Validates a profile name according to LXD requirements.
125    ///
126    /// # Arguments
127    ///
128    /// * `name` - The name to validate
129    ///
130    /// # Returns
131    ///
132    /// * `Ok(())` - If the name is valid
133    /// * `Err(ProfileNameError)` - If the name violates any requirement
134    fn validate(name: &str) -> Result<(), ProfileNameError> {
135        // Check length: must be between 1 and 63 characters
136        if name.is_empty() {
137            return Err(ProfileNameError::Empty);
138        }
139        if name.len() > 63 {
140            return Err(ProfileNameError::TooLong { length: name.len() });
141        }
142
143        // Check characters: only ASCII letters, numbers, and dashes
144        if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
145            return Err(ProfileNameError::InvalidCharacters);
146        }
147
148        // Check first character: must not be a digit or dash
149        if let Some(first_char) = name.chars().next() {
150            if first_char.is_ascii_digit() || first_char == '-' {
151                return Err(ProfileNameError::InvalidFirstCharacter);
152            }
153        }
154
155        // Check last character: must not be a dash
156        if let Some(last_char) = name.chars().last() {
157            if last_char == '-' {
158                return Err(ProfileNameError::InvalidLastCharacter);
159            }
160        }
161
162        Ok(())
163    }
164}
165
166impl fmt::Display for ProfileName {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        write!(f, "{}", self.0)
169    }
170}
171
172impl FromStr for ProfileName {
173    type Err = ProfileNameError;
174
175    fn from_str(s: &str) -> Result<Self, Self::Err> {
176        Self::new(s)
177    }
178}
179
180impl TryFrom<String> for ProfileName {
181    type Error = ProfileNameError;
182
183    fn try_from(value: String) -> Result<Self, Self::Error> {
184        Self::new(value)
185    }
186}
187
188impl From<ProfileName> for String {
189    fn from(profile_name: ProfileName) -> Self {
190        profile_name.0
191    }
192}
193
194impl AsRef<str> for ProfileName {
195    fn as_ref(&self) -> &str {
196        &self.0
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn it_should_create_valid_profile_name() {
206        let profile = ProfileName::new("torrust-profile").unwrap();
207        assert_eq!(profile.as_str(), "torrust-profile");
208    }
209
210    #[test]
211    fn it_should_accept_string_input() {
212        let profile_str = "test-profile".to_string();
213        let profile = ProfileName::new(profile_str).unwrap();
214        assert_eq!(profile.as_str(), "test-profile");
215    }
216
217    #[test]
218    fn it_should_accept_formatted_string() {
219        let env = "production";
220        let profile = ProfileName::new(format!("torrust-profile-{env}")).unwrap();
221        assert_eq!(profile.as_str(), "torrust-profile-production");
222    }
223
224    #[test]
225    fn it_should_create_profile_name_with_numbers() {
226        let profile = ProfileName::new("profile123").unwrap();
227        assert_eq!(profile.as_str(), "profile123");
228    }
229
230    #[test]
231    fn it_should_create_profile_name_with_dashes() {
232        let profile = ProfileName::new("test-profile-name").unwrap();
233        assert_eq!(profile.as_str(), "test-profile-name");
234    }
235
236    #[test]
237    fn it_should_reject_empty_profile_name() {
238        let result = ProfileName::new("");
239        assert!(matches!(result, Err(ProfileNameError::Empty)));
240    }
241
242    #[test]
243    fn it_should_reject_profile_name_starting_with_digit() {
244        let result = ProfileName::new("1profile");
245        assert!(matches!(
246            result,
247            Err(ProfileNameError::InvalidFirstCharacter)
248        ));
249    }
250
251    #[test]
252    fn it_should_reject_profile_name_starting_with_dash() {
253        let result = ProfileName::new("-profile");
254        assert!(matches!(
255            result,
256            Err(ProfileNameError::InvalidFirstCharacter)
257        ));
258    }
259
260    #[test]
261    fn it_should_reject_profile_name_ending_with_dash() {
262        let result = ProfileName::new("profile-");
263        assert!(matches!(
264            result,
265            Err(ProfileNameError::InvalidLastCharacter)
266        ));
267    }
268
269    #[test]
270    fn it_should_reject_profile_name_with_invalid_characters() {
271        let result = ProfileName::new("test@profile");
272        assert!(matches!(result, Err(ProfileNameError::InvalidCharacters)));
273
274        let result = ProfileName::new("test_profile");
275        assert!(matches!(result, Err(ProfileNameError::InvalidCharacters)));
276
277        let result = ProfileName::new("test profile");
278        assert!(matches!(result, Err(ProfileNameError::InvalidCharacters)));
279    }
280
281    #[test]
282    fn it_should_reject_profile_name_too_long() {
283        let long_name = "a".repeat(64);
284        let result = ProfileName::new(long_name);
285        assert!(matches!(
286            result,
287            Err(ProfileNameError::TooLong { length: 64 })
288        ));
289    }
290
291    #[test]
292    fn it_should_accept_max_length_profile_name() {
293        let max_length_name = "a".repeat(63);
294        let result = ProfileName::new(max_length_name);
295        assert!(result.is_ok());
296    }
297
298    #[test]
299    fn it_should_implement_from_str() {
300        let profile: ProfileName = "test-profile".parse().unwrap();
301        assert_eq!(profile.as_str(), "test-profile");
302
303        let invalid: Result<ProfileName, _> = "".parse();
304        assert!(invalid.is_err());
305    }
306
307    #[test]
308    fn it_should_implement_try_from_string() {
309        let profile = ProfileName::try_from("test-profile".to_string()).unwrap();
310        assert_eq!(profile.as_str(), "test-profile");
311
312        let invalid = ProfileName::try_from(String::new());
313        assert!(invalid.is_err());
314    }
315
316    #[test]
317    fn it_should_convert_to_string() {
318        let profile = ProfileName::new("test-profile").unwrap();
319        let string: String = profile.into();
320        assert_eq!(string, "test-profile");
321    }
322
323    #[test]
324    fn it_should_implement_as_ref() {
325        let profile = ProfileName::new("test-profile").unwrap();
326        let s: &str = profile.as_ref();
327        assert_eq!(s, "test-profile");
328    }
329
330    #[test]
331    fn it_should_display_profile_name() {
332        let profile = ProfileName::new("test-profile").unwrap();
333        assert_eq!(format!("{profile}"), "test-profile");
334    }
335
336    #[test]
337    fn it_should_be_cloneable() {
338        let profile = ProfileName::new("test-profile").unwrap();
339        let cloned = profile.clone();
340        assert_eq!(profile, cloned);
341    }
342
343    #[test]
344    fn it_should_be_hashable() {
345        use std::collections::HashMap;
346
347        let profile = ProfileName::new("test-profile").unwrap();
348        let mut map = HashMap::new();
349        map.insert(profile, "value");
350        assert_eq!(map.len(), 1);
351    }
352
353    #[test]
354    fn it_should_serialize_and_deserialize() {
355        let profile = ProfileName::new("test-profile").unwrap();
356        let json = serde_json::to_string(&profile).unwrap();
357        assert_eq!(json, "\"test-profile\"");
358
359        let deserialized: ProfileName = serde_json::from_str(&json).unwrap();
360        assert_eq!(deserialized, profile);
361    }
362
363    #[test]
364    fn it_should_fail_deserialization_for_invalid_names() {
365        let invalid_json = "\"\""; // Empty string
366        let result: Result<ProfileName, _> = serde_json::from_str(invalid_json);
367        assert!(result.is_err());
368
369        let invalid_json = "\"-invalid\""; // Starts with dash
370        let result: Result<ProfileName, _> = serde_json::from_str(invalid_json);
371        assert!(result.is_err());
372    }
373}