|
| 1 | +use std::fmt; |
1 | 2 | use std::net::IpAddr; |
| 3 | +use std::str::FromStr; |
2 | 4 |
|
3 | 5 | use anyhow::{anyhow, Context, Result}; |
4 | 6 | use serde_json::Value; |
5 | 7 | use tracing::info; |
6 | 8 |
|
7 | 9 | use crate::command::CommandExecutor; |
8 | 10 |
|
| 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 | + |
9 | 120 | /// Instance information from LXD |
10 | 121 | #[derive(Debug, Clone, PartialEq)] |
11 | 122 | pub struct InstanceInfo { |
12 | | - pub name: String, |
| 123 | + pub name: InstanceName, |
13 | 124 | pub ip_address: Option<IpAddr>, |
14 | 125 | } |
15 | 126 |
|
@@ -98,7 +209,7 @@ impl LxdClient { |
98 | 209 |
|
99 | 210 | Ok(instances |
100 | 211 | .into_iter() |
101 | | - .find(|inst| inst.name == instance_name)) |
| 212 | + .find(|inst| inst.name.as_str() == instance_name)) |
102 | 213 | } |
103 | 214 |
|
104 | 215 | /// List instances in JSON format |
@@ -166,10 +277,12 @@ impl LxdJsonParser { |
166 | 277 | let mut result = Vec::new(); |
167 | 278 |
|
168 | 279 | for instance_value in instances_array { |
169 | | - let name = instance_value["name"] |
| 280 | + let name_str = instance_value["name"] |
170 | 281 | .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}"))?; |
173 | 286 |
|
174 | 287 | let ip_address = Self::extract_ipv4_address(instance_value)?; |
175 | 288 |
|
@@ -253,7 +366,7 @@ mod tests { |
253 | 366 |
|
254 | 367 | let instances = LxdJsonParser::parse_instances_json(mock_json).unwrap(); |
255 | 368 | assert_eq!(instances.len(), 1); |
256 | | - assert_eq!(instances[0].name, "test-instance"); |
| 369 | + assert_eq!(instances[0].name.as_str(), "test-instance"); |
257 | 370 | assert_eq!( |
258 | 371 | instances[0].ip_address.unwrap().to_string(), |
259 | 372 | "192.168.1.100" |
@@ -292,7 +405,7 @@ mod tests { |
292 | 405 |
|
293 | 406 | let instances = LxdJsonParser::parse_instances_json(mock_json).unwrap(); |
294 | 407 | assert_eq!(instances.len(), 1); |
295 | | - assert_eq!(instances[0].name, "test-instance"); |
| 408 | + assert_eq!(instances[0].name.as_str(), "test-instance"); |
296 | 409 | assert!(instances[0].ip_address.is_none()); |
297 | 410 | } |
298 | 411 |
|
@@ -396,4 +509,146 @@ mod tests { |
396 | 509 | // the instance is not found or has no IP address. |
397 | 510 | // This is tested implicitly through the other unit tests of the parser. |
398 | 511 | } |
| 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 | + } |
399 | 654 | } |
0 commit comments