1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use crate::ServerError;
5
6use super::types::{LoadedSchema, ServerConfig};
7
8pub fn validate(config: &mut ServerConfig, base_dir: Option<&Path>) -> Result<(), ServerError> {
29 let mut errors = Vec::new();
30
31 validate_listen_address(config, &mut errors);
32 validate_health_listen_address(config, &mut errors);
33 validate_drain_timeout(config, &mut errors);
34 validate_channels(config, &mut errors);
35 validate_routing_rules(config, &mut errors);
36 validate_persistence_path(config, &mut errors);
37 validate_cluster(config, &mut errors);
38 validate_auth(config, &mut errors);
39 load_channel_schemas(config, base_dir, &mut errors);
40
41 if errors.is_empty() {
42 Ok(())
43 } else {
44 Err(ServerError::ConfigValidation {
45 message: errors.join("; "),
46 })
47 }
48}
49
50fn load_channel_schemas(
55 config: &mut ServerConfig,
56 base_dir: Option<&Path>,
57 errors: &mut Vec<String>,
58) {
59 for channel in &mut config.channels {
60 let Some(schema_ref) = channel.schema_ref.as_ref() else {
61 continue;
62 };
63 let path = resolve_schema_path(schema_ref, base_dir);
64 match load_schema_document(&path) {
65 Ok(loaded) => channel.loaded_schema = Some(loaded),
66 Err(reason) => errors.push(format!(
67 "channels.schema_ref '{}': {reason}",
68 schema_ref.display()
69 )),
70 }
71 }
72}
73
74fn resolve_schema_path(schema_ref: &Path, base_dir: Option<&Path>) -> PathBuf {
78 base_dir.map_or_else(|| schema_ref.to_path_buf(), |dir| dir.join(schema_ref))
81}
82
83fn load_schema_document(path: &Path) -> Result<LoadedSchema, String> {
90 let bytes = std::fs::read(path)
91 .map_err(|error| format!("schema file '{}' is unreadable: {error}", path.display()))?;
92 let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
93 format!(
94 "schema file '{}' is not valid JSON: {error}",
95 path.display()
96 )
97 })?;
98 liminal::channel::Schema::new(document.clone()).map_err(|error| {
99 format!(
100 "schema file '{}' is not a valid JSON Schema: {error}",
101 path.display()
102 )
103 })?;
104 Ok(LoadedSchema { bytes, document })
105}
106
107fn validate_listen_address(config: &ServerConfig, errors: &mut Vec<String>) {
108 if config.listen_address.port() == 0 {
109 errors.push("listen_address: port must be non-zero".to_owned());
110 }
111}
112
113fn validate_health_listen_address(config: &ServerConfig, errors: &mut Vec<String>) {
114 if config.health_listen_address.port() == 0 {
115 errors.push("health_listen_address: port must be non-zero".to_owned());
116 }
117
118 if config.health_listen_address == config.listen_address {
119 errors.push(
120 "health_listen_address: must differ from listen_address for probe isolation".to_owned(),
121 );
122 } else if config.health_listen_address.port() == config.listen_address.port() {
123 errors.push(
124 "health_listen_address: port must differ from listen_address port for probe isolation"
125 .to_owned(),
126 );
127 }
128}
129
130fn validate_drain_timeout(config: &ServerConfig, errors: &mut Vec<String>) {
131 if config.drain_timeout_ms == 0 {
132 errors.push("drain_timeout_ms: must be greater than zero".to_owned());
133 }
134}
135
136fn validate_channels(config: &ServerConfig, errors: &mut Vec<String>) {
137 let mut seen = BTreeSet::new();
138 let mut duplicates = BTreeSet::new();
139
140 for channel in &config.channels {
141 let name = channel.name.trim();
142 if name.is_empty() {
143 errors.push("channels.name: channel name must not be empty".to_owned());
144 continue;
145 }
146
147 if !seen.insert(name.to_owned()) {
148 duplicates.insert(name.to_owned());
149 }
150 }
151
152 if !duplicates.is_empty() {
153 let names = duplicates.into_iter().collect::<Vec<_>>().join(", ");
154 errors.push(format!("channels.name: duplicate channel names: {names}"));
155 }
156}
157
158fn validate_routing_rules(config: &ServerConfig, errors: &mut Vec<String>) {
159 let channel_names = config
160 .channels
161 .iter()
162 .map(|channel| channel.name.as_str())
163 .collect::<BTreeSet<_>>();
164
165 for (index, rule) in config.routing_rules.iter().enumerate() {
166 let source = rule.source_channel.trim();
167 if source.is_empty() {
168 errors.push(format!(
169 "routing_rules[{index}].source_channel: source channel must not be empty"
170 ));
171 } else if !channel_names.contains(source) {
172 errors.push(format!(
173 "routing_rules[{index}].source_channel: unknown channel '{source}'"
174 ));
175 }
176
177 let target = rule.target_channel.trim();
178 if target.is_empty() {
179 errors.push(format!(
180 "routing_rules[{index}].target_channel: target channel must not be empty"
181 ));
182 } else if !channel_names.contains(target) {
183 errors.push(format!(
184 "routing_rules[{index}].target_channel: unknown channel '{target}'"
185 ));
186 }
187 }
188}
189
190fn validate_persistence_path(config: &ServerConfig, errors: &mut Vec<String>) {
191 let Some(path) = config.persistence_path.as_deref() else {
192 return;
193 };
194
195 match std::fs::metadata(path) {
196 Ok(metadata) => {
197 if !metadata.is_dir() {
198 errors.push(format!(
199 "persistence_path '{}': path must be an existing directory",
200 path.display()
201 ));
202 } else if metadata.permissions().readonly() {
203 errors.push(format!(
204 "persistence_path '{}': path is not writable",
205 path.display()
206 ));
207 }
208 }
209 Err(error) => {
210 errors.push(format!(
211 "persistence_path '{}': path is unreachable: {error}",
212 path.display()
213 ));
214 }
215 }
216}
217
218fn validate_cluster(config: &ServerConfig, errors: &mut Vec<String>) {
219 let Some(cluster) = config.cluster.as_ref() else {
220 return;
221 };
222
223 if cluster.node_name.trim().is_empty() {
224 errors.push("cluster.node_name: node name must not be empty".to_owned());
225 }
226
227 if cluster.cookie.is_empty() {
228 errors.push("cluster.cookie: distribution cookie must not be empty".to_owned());
229 }
230
231 if cluster.listen_address.port() == 0 {
232 errors.push("cluster.listen_address: distribution port must be non-zero".to_owned());
233 }
234
235 if cluster.listen_address == config.listen_address {
236 errors.push(
237 "cluster.listen_address: distribution port must differ from the client listen_address"
238 .to_owned(),
239 );
240 }
241
242 let mut seed_node_counts = BTreeMap::new();
243 for (index, seed_node) in cluster.seed_nodes.iter().enumerate() {
244 if seed_node.port() == 0 {
245 errors.push(format!(
246 "cluster.seed_nodes[{index}]: seed node port must be non-zero"
247 ));
248 }
249 seed_node_counts
250 .entry(seed_node.to_string())
251 .and_modify(|count| *count += 1)
252 .or_insert(1_usize);
253 }
254
255 let duplicates = seed_node_counts
256 .into_iter()
257 .filter_map(|(seed_node, count)| (count > 1).then_some(seed_node))
258 .collect::<Vec<_>>();
259
260 if !duplicates.is_empty() {
261 errors.push(format!(
262 "cluster.seed_nodes: duplicate seed nodes: {}",
263 duplicates.join(", ")
264 ));
265 }
266}
267
268fn validate_auth(config: &ServerConfig, errors: &mut Vec<String>) {
274 let Some(auth) = config.auth.as_ref() else {
275 return;
276 };
277
278 if auth.token.is_empty() {
279 errors.push("auth.token: authentication token must not be empty".to_owned());
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use std::fs;
286 use std::net::SocketAddr;
287 use std::path::PathBuf;
288 use std::sync::atomic::{AtomicU64, Ordering};
289
290 use crate::ServerError;
291
292 use super::validate;
293 use crate::config::types::{
294 AuthConfig, ChannelDef, ClusterConfig, RoutingRuleDef, ServerConfig,
295 };
296
297 static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
298
299 fn socket(address: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
300 Ok(address.parse()?)
301 }
302
303 fn sample_config() -> Result<ServerConfig, Box<dyn std::error::Error>> {
304 Ok(ServerConfig {
305 listen_address: socket("127.0.0.1:8080")?,
306 health_listen_address: socket("127.0.0.1:8081")?,
307 drain_timeout_ms: 30_000,
308 channels: vec![ChannelDef {
309 name: "orders".to_owned(),
310 schema_ref: None,
311 durable: true,
312 loaded_schema: None,
313 }],
314 routing_rules: vec![RoutingRuleDef {
315 source_channel: "orders".to_owned(),
316 target_channel: "orders".to_owned(),
317 predicate: None,
318 }],
319 persistence_path: None,
320 cluster: Some(ClusterConfig {
321 node_name: "node-a".to_owned(),
322 listen_address: socket("127.0.0.1:9000")?,
323 seed_nodes: vec![socket("127.0.0.1:9001")?],
324 cookie: "test-cookie".to_owned(),
325 }),
326 auth: None,
327 })
328 }
329
330 fn unique_temp_dir(label: &str) -> PathBuf {
331 let id = NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
332 std::env::temp_dir().join(format!(
333 "liminal-server-validation-{label}-{}-{id}",
334 std::process::id()
335 ))
336 }
337
338 fn config_validation_message(result: Result<(), ServerError>) -> String {
339 let Err(ServerError::ConfigValidation { message }) = result else {
340 return String::new();
341 };
342 message
343 }
344
345 #[test]
346 fn valid_config_passes_validation() -> Result<(), Box<dyn std::error::Error>> {
347 let mut config = sample_config()?;
348
349 validate(&mut config, None)?;
350
351 Ok(())
352 }
353
354 #[test]
355 fn invalid_listen_address_reports_field_name() -> Result<(), Box<dyn std::error::Error>> {
356 let mut config = sample_config()?;
357 config.listen_address = socket("127.0.0.1:0")?;
358
359 let message = config_validation_message(validate(&mut config, None));
360
361 assert!(message.contains("listen_address"));
362 assert!(message.contains("port"));
363
364 Ok(())
365 }
366
367 #[test]
368 fn invalid_health_listen_address_reports_field_name() -> Result<(), Box<dyn std::error::Error>>
369 {
370 let mut config = sample_config()?;
371 config.health_listen_address = socket("127.0.0.1:0")?;
372
373 let message = config_validation_message(validate(&mut config, None));
374
375 assert!(message.contains("health_listen_address"));
376 assert!(message.contains("port"));
377
378 Ok(())
379 }
380
381 #[test]
382 fn matching_health_and_main_listen_addresses_are_rejected()
383 -> Result<(), Box<dyn std::error::Error>> {
384 let mut config = sample_config()?;
385 config.health_listen_address = config.listen_address;
386
387 let message = config_validation_message(validate(&mut config, None));
388
389 assert!(message.contains("health_listen_address"));
390 assert!(message.contains("listen_address"));
391
392 Ok(())
393 }
394
395 #[test]
396 fn matching_health_and_main_listen_ports_are_rejected() -> Result<(), Box<dyn std::error::Error>>
397 {
398 let mut config = sample_config()?;
399 config.health_listen_address = socket("0.0.0.0:8080")?;
400
401 let message = config_validation_message(validate(&mut config, None));
402
403 assert!(message.contains("health_listen_address"));
404 assert!(message.contains("port"));
405
406 Ok(())
407 }
408
409 #[test]
410 fn zero_drain_timeout_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
411 let mut config = sample_config()?;
412 config.drain_timeout_ms = 0;
413
414 let message = config_validation_message(validate(&mut config, None));
415
416 assert!(message.contains("drain_timeout_ms"));
417 assert!(message.contains("greater than zero"));
418
419 Ok(())
420 }
421
422 #[test]
423 fn duplicate_channel_names_are_listed() -> Result<(), Box<dyn std::error::Error>> {
424 let mut config = sample_config()?;
425 config.channels.push(ChannelDef {
426 name: "orders".to_owned(),
427 schema_ref: None,
428 durable: false,
429 loaded_schema: None,
430 });
431
432 let message = config_validation_message(validate(&mut config, None));
433
434 assert!(message.contains("duplicate"));
435 assert!(message.contains("orders"));
436
437 Ok(())
438 }
439
440 #[test]
441 fn unreachable_persistence_path_reports_path() -> Result<(), Box<dyn std::error::Error>> {
442 let mut config = sample_config()?;
443 let path = unique_temp_dir("missing");
444 config.persistence_path = Some(path.clone());
445
446 let message = config_validation_message(validate(&mut config, None));
447
448 assert!(message.contains("persistence_path"));
449 assert!(message.contains(&path.display().to_string()));
450
451 Ok(())
452 }
453
454 #[test]
455 fn file_persistence_path_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
456 let mut config = sample_config()?;
457 let path = unique_temp_dir("file");
458 fs::write(&path, "not a directory")?;
459 config.persistence_path = Some(path.clone());
460
461 let message = config_validation_message(validate(&mut config, None));
462 fs::remove_file(&path)?;
463
464 assert!(message.contains("persistence_path"));
465 assert!(message.contains("directory"));
466
467 Ok(())
468 }
469
470 #[test]
471 fn multiple_validation_errors_are_reported_together() -> Result<(), Box<dyn std::error::Error>>
472 {
473 let mut config = sample_config()?;
474 let missing_path = unique_temp_dir("multi-missing");
475 config.listen_address = socket("127.0.0.1:0")?;
476 config.channels.push(ChannelDef {
477 name: "orders".to_owned(),
478 schema_ref: None,
479 durable: false,
480 loaded_schema: None,
481 });
482 config.persistence_path = Some(missing_path.clone());
483
484 let message = config_validation_message(validate(&mut config, None));
485
486 assert!(message.contains("listen_address"));
487 assert!(message.contains("duplicate channel names: orders"));
488 assert!(message.contains(&missing_path.display().to_string()));
489
490 Ok(())
491 }
492
493 #[test]
494 fn routing_rules_reference_configured_channels() -> Result<(), Box<dyn std::error::Error>> {
495 let mut config = sample_config()?;
496 config.routing_rules[0].target_channel = "unknown".to_owned();
497
498 let message = config_validation_message(validate(&mut config, None));
499
500 assert!(message.contains("routing_rules[0].target_channel"));
501 assert!(message.contains("unknown"));
502
503 Ok(())
504 }
505
506 fn write_temp_schema(
508 label: &str,
509 contents: &str,
510 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
511 let path = unique_temp_dir(label).with_extension("json");
512 fs::write(&path, contents)?;
513 Ok(path)
514 }
515
516 #[test]
517 fn absolute_schema_ref_is_loaded_and_parsed() -> Result<(), Box<dyn std::error::Error>> {
518 let schema = r#"{"type":"object","properties":{"id":{"type":"integer"}}}"#;
519 let schema_path = write_temp_schema("load-ok", schema)?;
520 let mut config = sample_config()?;
521 config.channels[0].schema_ref = Some(schema_path.clone());
522
523 let result = validate(&mut config, None);
524 fs::remove_file(&schema_path)?;
525 result?;
526
527 let loaded = config.channels[0]
528 .loaded_schema
529 .as_ref()
530 .ok_or("schema should have been loaded onto the channel")?;
531 assert_eq!(loaded.bytes, schema.as_bytes());
532 assert_eq!(
533 loaded.document.get("type").and_then(|t| t.as_str()),
534 Some("object")
535 );
536
537 Ok(())
538 }
539
540 #[test]
541 fn relative_schema_ref_resolves_against_base_dir() -> Result<(), Box<dyn std::error::Error>> {
542 let dir = unique_temp_dir("relative-base");
543 fs::create_dir_all(&dir)?;
544 let schema = r#"{"type":"object"}"#;
545 fs::write(dir.join("orders.json"), schema)?;
546
547 let mut config = sample_config()?;
548 config.channels[0].schema_ref = Some(PathBuf::from("orders.json"));
549
550 let result = validate(&mut config, Some(&dir));
551 fs::remove_dir_all(&dir)?;
552 result?;
553
554 assert!(config.channels[0].loaded_schema.is_some());
555
556 Ok(())
557 }
558
559 #[test]
560 fn missing_schema_ref_file_reports_validation_error() -> Result<(), Box<dyn std::error::Error>>
561 {
562 let missing = unique_temp_dir("missing-schema").with_extension("json");
563 let mut config = sample_config()?;
564 config.channels[0].schema_ref = Some(missing.clone());
565
566 let message = config_validation_message(validate(&mut config, None));
567
568 assert!(message.contains("schema_ref"));
569 assert!(message.contains(&missing.display().to_string()));
570 assert!(message.contains("unreadable"));
571
572 Ok(())
573 }
574
575 #[test]
576 fn invalid_json_schema_ref_reports_validation_error() -> Result<(), Box<dyn std::error::Error>>
577 {
578 let schema_path = write_temp_schema("bad-json", "{ this is not json")?;
579 let mut config = sample_config()?;
580 config.channels[0].schema_ref = Some(schema_path.clone());
581
582 let message = config_validation_message(validate(&mut config, None));
583 fs::remove_file(&schema_path)?;
584
585 assert!(message.contains("schema_ref"));
586 assert!(message.contains("not valid JSON"));
587
588 Ok(())
589 }
590
591 #[test]
592 fn valid_json_invalid_schema_ref_reports_validation_error()
593 -> Result<(), Box<dyn std::error::Error>> {
594 let schema_path = write_temp_schema("bad-schema", "[]")?;
597 let mut config = sample_config()?;
598 config.channels[0].schema_ref = Some(schema_path.clone());
599
600 let message = config_validation_message(validate(&mut config, None));
601 fs::remove_file(&schema_path)?;
602
603 assert!(message.contains("schema_ref"));
604 assert!(message.contains("not a valid JSON Schema"));
605
606 Ok(())
607 }
608
609 #[test]
610 fn present_non_empty_auth_token_passes_validation() -> Result<(), Box<dyn std::error::Error>> {
611 let mut config = sample_config()?;
612 config.auth = Some(AuthConfig {
613 token: "s3cr3t".to_owned(),
614 });
615
616 validate(&mut config, None)?;
617
618 Ok(())
619 }
620
621 #[test]
622 fn empty_auth_token_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
623 let mut config = sample_config()?;
624 config.auth = Some(AuthConfig {
625 token: String::new(),
626 });
627
628 let message = config_validation_message(validate(&mut config, None));
629
630 assert!(message.contains("auth.token"));
631 assert!(message.contains("must not be empty"));
632
633 Ok(())
634 }
635
636 #[test]
637 fn absent_auth_section_passes_validation() -> Result<(), Box<dyn std::error::Error>> {
638 let mut config = sample_config()?;
639 config.auth = None;
640
641 validate(&mut config, None)?;
642
643 Ok(())
644 }
645}