Skip to content

Commit 8324047

Browse files
authored
Merge pull request #416 from lettalkdata-cloud/feature/crd-validation-366
Feature/crd validation 366
2 parents 35bf12d + 49d712a commit 8324047

5 files changed

Lines changed: 202 additions & 2 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Failure case: empty customName
2+
# Expected: ValidationError from kubectl — spec.network fails minLength: 1
3+
# kubectl apply -f this file should be rejected by the API server.
4+
apiVersion: stellar.org/v1alpha1
5+
kind: StellarNode
6+
metadata:
7+
name: invalid-empty-network
8+
namespace: default
9+
spec:
10+
nodeType: Horizon
11+
network: ""
12+
version: "v21.0.0"
13+
horizonConfig:
14+
databaseSecretRef: "horizon-db"
15+
enableIngest: true
16+
stellarCoreUrl: "http://stellar-core:11626"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Failure case: special characters in customName
2+
# Expected: ValidationError from kubectl — spec.network fails pattern validation
3+
# kubectl apply -f this file should be rejected by the API server.
4+
#
5+
# Demonstrates that shell-injection-style strings like "; drop table" are rejected.
6+
apiVersion: stellar.org/v1alpha1
7+
kind: StellarNode
8+
metadata:
9+
name: invalid-special-chars-network
10+
namespace: default
11+
spec:
12+
nodeType: Horizon
13+
network: "Custom:my@network$name!"
14+
version: "v21.0.0"
15+
horizonConfig:
16+
databaseSecretRef: "horizon-db"
17+
enableIngest: true
18+
stellarCoreUrl: "http://stellar-core:11626"

src/crd/stellar_node.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,15 @@ impl StellarNodeSpec {
282282
pub fn validate(&self) -> Result<(), Vec<SpecValidationError>> {
283283
let mut errors: Vec<SpecValidationError> = Vec::new();
284284

285+
// 0. Custom network name validation
286+
if let Err(msg) = self.network.validate_custom_name() {
287+
errors.push(SpecValidationError::new(
288+
"spec.network.customName",
289+
msg,
290+
"Use only lowercase alphanumeric characters and hyphens, 1–63 characters, no leading/trailing hyphens (DNS-1123). Example: \"my-private-net\".",
291+
));
292+
}
293+
285294
// 1. Database Mutual Exclusion
286295
if self.database.is_some() && self.managed_database.is_some() {
287296
errors.push(SpecValidationError::new(

src/crd/tests.rs

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -759,11 +759,135 @@ mod stellar_node_spec_validation {
759759
#[test]
760760
fn test_validator_custom_network_passes() {
761761
let mut spec = valid_validator_spec();
762-
spec.network = StellarNetwork::Custom;
763-
spec.custom_network_passphrase = Some("My Private Network".to_string());
762+
spec.network = StellarNetwork::Custom("my-private-network".to_string());
764763
assert!(spec.validate().is_ok());
765764
}
766765

766+
// =========================================================================
767+
// Custom Network Name Validation Tests (#366)
768+
// =========================================================================
769+
770+
#[test]
771+
fn test_custom_network_valid_alphanumeric_passes() {
772+
let mut spec = valid_validator_spec();
773+
spec.network = StellarNetwork::Custom("my-private-net".to_string());
774+
assert!(spec.validate().is_ok());
775+
}
776+
777+
#[test]
778+
fn test_custom_network_single_char_passes() {
779+
let mut spec = valid_validator_spec();
780+
spec.network = StellarNetwork::Custom("a".to_string());
781+
assert!(spec.validate().is_ok());
782+
}
783+
784+
#[test]
785+
fn test_custom_network_63_chars_passes() {
786+
let mut spec = valid_validator_spec();
787+
spec.network = StellarNetwork::Custom("a".repeat(63));
788+
assert!(spec.validate().is_ok());
789+
}
790+
791+
#[test]
792+
fn test_custom_network_empty_fails() {
793+
let mut spec = valid_validator_spec();
794+
spec.network = StellarNetwork::Custom("".to_string());
795+
let result = spec.validate();
796+
assert!(result.is_err());
797+
let errors = result.unwrap_err();
798+
assert!(errors
799+
.iter()
800+
.any(|e| e.field == "spec.network.customName" && e.message.contains("empty")));
801+
}
802+
803+
#[test]
804+
fn test_custom_network_exceeds_63_chars_fails() {
805+
let mut spec = valid_validator_spec();
806+
spec.network = StellarNetwork::Custom("a".repeat(64));
807+
let result = spec.validate();
808+
assert!(result.is_err());
809+
let errors = result.unwrap_err();
810+
assert!(errors
811+
.iter()
812+
.any(|e| e.field == "spec.network.customName" && e.message.contains("63")));
813+
}
814+
815+
#[test]
816+
fn test_custom_network_special_chars_fails() {
817+
for bad in &["; drop table", "my@network", "net$name!", "net name"] {
818+
let mut spec = valid_validator_spec();
819+
spec.network = StellarNetwork::Custom(bad.to_string());
820+
let result = spec.validate();
821+
assert!(
822+
result.is_err(),
823+
"Expected validation failure for customName: '{bad}'"
824+
);
825+
let errors = result.unwrap_err();
826+
assert!(
827+
errors.iter().any(|e| e.field == "spec.network.customName"),
828+
"Expected spec.network.customName error for '{bad}'"
829+
);
830+
}
831+
}
832+
833+
#[test]
834+
fn test_custom_network_leading_hyphen_fails() {
835+
let mut spec = valid_validator_spec();
836+
spec.network = StellarNetwork::Custom("-bad-name".to_string());
837+
let result = spec.validate();
838+
assert!(result.is_err());
839+
assert!(result
840+
.unwrap_err()
841+
.iter()
842+
.any(|e| e.field == "spec.network.customName"));
843+
}
844+
845+
#[test]
846+
fn test_custom_network_trailing_hyphen_fails() {
847+
let mut spec = valid_validator_spec();
848+
spec.network = StellarNetwork::Custom("bad-name-".to_string());
849+
let result = spec.validate();
850+
assert!(result.is_err());
851+
assert!(result
852+
.unwrap_err()
853+
.iter()
854+
.any(|e| e.field == "spec.network.customName"));
855+
}
856+
857+
#[test]
858+
fn test_non_custom_networks_skip_name_validation() {
859+
for network in [
860+
StellarNetwork::Mainnet,
861+
StellarNetwork::Testnet,
862+
StellarNetwork::Futurenet,
863+
] {
864+
let mut spec = valid_validator_spec();
865+
spec.network = network;
866+
assert!(spec.validate().is_ok());
867+
}
868+
}
869+
870+
#[test]
871+
fn test_custom_network_uppercase_fails() {
872+
// DNS-1123 requires lowercase; uppercase must be rejected
873+
for bad in &["MyNetwork", "My-Net", "MYNET"] {
874+
let mut spec = valid_validator_spec();
875+
spec.network = StellarNetwork::Custom(bad.to_string());
876+
let result = spec.validate();
877+
assert!(
878+
result.is_err(),
879+
"Expected validation failure for uppercase customName: '{bad}'"
880+
);
881+
assert!(
882+
result
883+
.unwrap_err()
884+
.iter()
885+
.any(|e| e.field == "spec.network.customName"),
886+
"Expected spec.network.customName error for '{bad}'"
887+
);
888+
}
889+
}
890+
767891
// =========================================================================
768892
// Edge Cases and Boundary Tests
769893
// =========================================================================

src/crd/types.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,39 @@ impl StellarNetwork {
8585
}
8686
}
8787

88+
/// Validate the custom network name against DNS-1123 label rules.
89+
///
90+
/// Rules (applied only to `Custom` variants):
91+
/// - Must not be empty (minLength: 1)
92+
/// - Must not exceed 63 characters (maxLength: 63)
93+
/// - Must match `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` (lowercase alphanumeric and hyphens,
94+
/// no leading/trailing hyphens)
95+
pub fn validate_custom_name(&self) -> Result<(), String> {
96+
let name = match self {
97+
StellarNetwork::Custom(n) => n,
98+
_ => return Ok(()),
99+
};
100+
if name.is_empty() {
101+
return Err("customName must not be empty (minLength: 1)".to_string());
102+
}
103+
if name.len() > 63 {
104+
return Err(format!(
105+
"customName '{name}' exceeds 63 characters (maxLength: 63)"
106+
));
107+
}
108+
let valid = name
109+
.chars()
110+
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
111+
&& name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
112+
&& name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit());
113+
if !valid {
114+
return Err(format!(
115+
"customName '{name}' is invalid: must match ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ (lowercase alphanumeric and hyphens only, no leading/trailing hyphens)"
116+
));
117+
}
118+
Ok(())
119+
}
120+
88121
/// Stable, DNS-1123-friendly label value for topology spread and anti-affinity.
89122
pub fn scheduling_label_value(&self, custom: &Option<String>) -> String {
90123
match self {

0 commit comments

Comments
 (0)