Skip to content

Commit 9b53e89

Browse files
committed
refactor: add InstanceName newtype for LXD instance validation
- Create InstanceName newtype using Rust newtype pattern - Enforce LXD naming requirements: 1-63 chars, ASCII letters/numbers/dashes only, cannot start/end with digit or dash - Add comprehensive unit tests covering all validation rules and edge cases - Update InstanceInfo struct to use InstanceName instead of String - Update JSON parser to validate instance names from LXD responses - Maintain backward compatibility with as_str() method - All tests and linters pass
1 parent 6fd1184 commit 9b53e89

File tree

1 file changed

+262
-7
lines changed

1 file changed

+262
-7
lines changed

src/lxd.rs

Lines changed: 262 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,126 @@
1+
use std::fmt;
12
use std::net::IpAddr;
3+
use std::str::FromStr;
24

35
use anyhow::{anyhow, Context, Result};
46
use serde_json::Value;
57
use tracing::info;
68

79
use crate::command::CommandExecutor;
810

11+
/// A validated LXD instance name following LXD naming requirements.
12+
///
13+
/// Valid instance names must fulfill the following requirements:
14+
/// - The name must be between 1 and 63 characters long
15+
/// - The name must contain only letters, numbers and dashes from the ASCII table
16+
/// - The name must not start with a digit or a dash
17+
/// - The name must not end with a dash
18+
///
19+
/// These requirements ensure that the instance name can be used in DNS records,
20+
/// on the file system, in various security profiles and as the host name.
21+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22+
pub struct InstanceName(String);
23+
24+
impl InstanceName {
25+
/// Creates a new `InstanceName` from a string if it's valid.
26+
///
27+
/// # Arguments
28+
///
29+
/// * `name` - The instance name to validate
30+
///
31+
/// # Returns
32+
///
33+
/// * `Ok(InstanceName)` - If the name is valid
34+
/// * `Err(anyhow::Error)` - If the name violates LXD naming requirements
35+
///
36+
/// # Errors
37+
///
38+
/// This function will return an error if the name violates any LXD naming requirements:
39+
/// * Empty name
40+
/// * Name longer than 63 characters
41+
/// * Name contains non-ASCII letters, numbers, or dashes
42+
/// * Name starts with a digit or dash
43+
/// * Name ends with a dash
44+
pub fn new(name: String) -> Result<Self> {
45+
Self::validate(&name)?;
46+
Ok(Self(name))
47+
}
48+
49+
/// Returns the instance name as a string slice.
50+
#[must_use]
51+
pub fn as_str(&self) -> &str {
52+
&self.0
53+
}
54+
55+
/// Validates an instance name according to LXD requirements.
56+
///
57+
/// # Arguments
58+
///
59+
/// * `name` - The name to validate
60+
///
61+
/// # Returns
62+
///
63+
/// * `Ok(())` - If the name is valid
64+
/// * `Err(anyhow::Error)` - If the name violates any requirement
65+
fn validate(name: &str) -> Result<()> {
66+
// Check length: must be between 1 and 63 characters
67+
if name.is_empty() {
68+
return Err(anyhow!("Instance name cannot be empty"));
69+
}
70+
if name.len() > 63 {
71+
return Err(anyhow!(
72+
"Instance name must be 63 characters or less, got {} characters",
73+
name.len()
74+
));
75+
}
76+
77+
// Check characters: only ASCII letters, numbers, and dashes
78+
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
79+
return Err(anyhow!(
80+
"Instance name must contain only ASCII letters, numbers, and dashes"
81+
));
82+
}
83+
84+
// Check first character: must not be a digit or dash
85+
if let Some(first_char) = name.chars().next() {
86+
if first_char.is_ascii_digit() || first_char == '-' {
87+
return Err(anyhow!("Instance name must not start with a digit or dash"));
88+
}
89+
}
90+
91+
// Check last character: must not be a dash
92+
if name.ends_with('-') {
93+
return Err(anyhow!("Instance name must not end with a dash"));
94+
}
95+
96+
Ok(())
97+
}
98+
}
99+
100+
impl fmt::Display for InstanceName {
101+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102+
write!(f, "{}", self.0)
103+
}
104+
}
105+
106+
impl FromStr for InstanceName {
107+
type Err = anyhow::Error;
108+
109+
fn from_str(s: &str) -> Result<Self, Self::Err> {
110+
Self::new(s.to_string())
111+
}
112+
}
113+
114+
impl AsRef<str> for InstanceName {
115+
fn as_ref(&self) -> &str {
116+
&self.0
117+
}
118+
}
119+
9120
/// Instance information from LXD
10121
#[derive(Debug, Clone, PartialEq)]
11122
pub struct InstanceInfo {
12-
pub name: String,
123+
pub name: InstanceName,
13124
pub ip_address: Option<IpAddr>,
14125
}
15126

@@ -98,7 +209,7 @@ impl LxdClient {
98209

99210
Ok(instances
100211
.into_iter()
101-
.find(|inst| inst.name == instance_name))
212+
.find(|inst| inst.name.as_str() == instance_name))
102213
}
103214

104215
/// List instances in JSON format
@@ -166,10 +277,12 @@ impl LxdJsonParser {
166277
let mut result = Vec::new();
167278

168279
for instance_value in instances_array {
169-
let name = instance_value["name"]
280+
let name_str = instance_value["name"]
170281
.as_str()
171-
.ok_or_else(|| anyhow!("Instance missing name field"))?
172-
.to_string();
282+
.ok_or_else(|| anyhow!("Instance missing name field"))?;
283+
284+
let name = InstanceName::new(name_str.to_string())
285+
.with_context(|| format!("Invalid instance name: {name_str}"))?;
173286

174287
let ip_address = Self::extract_ipv4_address(instance_value)?;
175288

@@ -253,7 +366,7 @@ mod tests {
253366

254367
let instances = LxdJsonParser::parse_instances_json(mock_json).unwrap();
255368
assert_eq!(instances.len(), 1);
256-
assert_eq!(instances[0].name, "test-instance");
369+
assert_eq!(instances[0].name.as_str(), "test-instance");
257370
assert_eq!(
258371
instances[0].ip_address.unwrap().to_string(),
259372
"192.168.1.100"
@@ -292,7 +405,7 @@ mod tests {
292405

293406
let instances = LxdJsonParser::parse_instances_json(mock_json).unwrap();
294407
assert_eq!(instances.len(), 1);
295-
assert_eq!(instances[0].name, "test-instance");
408+
assert_eq!(instances[0].name.as_str(), "test-instance");
296409
assert!(instances[0].ip_address.is_none());
297410
}
298411

@@ -396,4 +509,146 @@ mod tests {
396509
// the instance is not found or has no IP address.
397510
// This is tested implicitly through the other unit tests of the parser.
398511
}
512+
513+
// Tests for InstanceName
514+
#[test]
515+
fn it_should_create_valid_instance_name() {
516+
let name = InstanceName::new("test-instance".to_string()).unwrap();
517+
assert_eq!(name.as_str(), "test-instance");
518+
}
519+
520+
#[test]
521+
fn it_should_create_instance_name_with_numbers() {
522+
let name = InstanceName::new("test123".to_string()).unwrap();
523+
assert_eq!(name.as_str(), "test123");
524+
}
525+
526+
#[test]
527+
fn it_should_create_instance_name_with_dashes() {
528+
let name = InstanceName::new("test-instance-name".to_string()).unwrap();
529+
assert_eq!(name.as_str(), "test-instance-name");
530+
}
531+
532+
#[test]
533+
fn it_should_create_single_character_name() {
534+
let name = InstanceName::new("a".to_string()).unwrap();
535+
assert_eq!(name.as_str(), "a");
536+
}
537+
538+
#[test]
539+
fn it_should_create_63_character_name() {
540+
let long_name = "a".repeat(63);
541+
let name = InstanceName::new(long_name.clone()).unwrap();
542+
assert_eq!(name.as_str(), long_name);
543+
}
544+
545+
#[test]
546+
fn it_should_reject_empty_name() {
547+
let result = InstanceName::new(String::new());
548+
assert!(result.is_err());
549+
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
550+
}
551+
552+
#[test]
553+
fn it_should_reject_name_longer_than_63_characters() {
554+
let long_name = "a".repeat(64);
555+
let result = InstanceName::new(long_name);
556+
assert!(result.is_err());
557+
assert!(result
558+
.unwrap_err()
559+
.to_string()
560+
.contains("63 characters or less"));
561+
}
562+
563+
#[test]
564+
fn it_should_reject_name_starting_with_digit() {
565+
let result = InstanceName::new("1test".to_string());
566+
assert!(result.is_err());
567+
assert!(result
568+
.unwrap_err()
569+
.to_string()
570+
.contains("must not start with a digit or dash"));
571+
}
572+
573+
#[test]
574+
fn it_should_reject_name_starting_with_dash() {
575+
let result = InstanceName::new("-test".to_string());
576+
assert!(result.is_err());
577+
assert!(result
578+
.unwrap_err()
579+
.to_string()
580+
.contains("must not start with a digit or dash"));
581+
}
582+
583+
#[test]
584+
fn it_should_reject_name_ending_with_dash() {
585+
let result = InstanceName::new("test-".to_string());
586+
assert!(result.is_err());
587+
assert!(result
588+
.unwrap_err()
589+
.to_string()
590+
.contains("must not end with a dash"));
591+
}
592+
593+
#[test]
594+
fn it_should_reject_name_with_invalid_characters() {
595+
let invalid_chars = vec![
596+
"test@instance",
597+
"test.instance",
598+
"test_instance",
599+
"test instance",
600+
"test#instance",
601+
];
602+
603+
for invalid_name in invalid_chars {
604+
let result = InstanceName::new(invalid_name.to_string());
605+
assert!(result.is_err());
606+
assert!(result
607+
.unwrap_err()
608+
.to_string()
609+
.contains("must contain only ASCII letters, numbers, and dashes"));
610+
}
611+
}
612+
613+
#[test]
614+
fn it_should_reject_name_with_unicode_characters() {
615+
let result = InstanceName::new("tést-instance".to_string());
616+
assert!(result.is_err());
617+
assert!(result
618+
.unwrap_err()
619+
.to_string()
620+
.contains("must contain only ASCII letters, numbers, and dashes"));
621+
}
622+
623+
#[test]
624+
fn it_should_display_instance_name() {
625+
let name = InstanceName::new("test-instance".to_string()).unwrap();
626+
assert_eq!(format!("{name}"), "test-instance");
627+
}
628+
629+
#[test]
630+
fn it_should_parse_valid_name_from_string() {
631+
let name: InstanceName = "test-instance".parse().unwrap();
632+
assert_eq!(name.as_str(), "test-instance");
633+
}
634+
635+
#[test]
636+
fn it_should_fail_parsing_invalid_name_from_string() {
637+
let result: Result<InstanceName, _> = "test-".parse();
638+
assert!(result.is_err());
639+
}
640+
641+
#[test]
642+
fn it_should_implement_as_ref() {
643+
let name = InstanceName::new("test-instance".to_string()).unwrap();
644+
let as_ref: &str = name.as_ref();
645+
assert_eq!(as_ref, "test-instance");
646+
}
647+
648+
#[test]
649+
fn it_should_be_cloneable_and_comparable() {
650+
let name1 = InstanceName::new("test-instance".to_string()).unwrap();
651+
let name2 = name1.clone();
652+
assert_eq!(name1, name2);
653+
}
399654
}

0 commit comments

Comments
 (0)