Skip to main content

liminal_server/config/
validation.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use crate::ServerError;
5
6use super::types::{LoadedSchema, ServerConfig};
7
8/// Validates a fully loaded server configuration before startup.
9///
10/// Validation is intentionally limited to deterministic semantic checks and
11/// filesystem inspection. It does not bind sockets, connect to peers, or perform
12/// any other network I/O. Beyond the semantic checks it also resolves and loads
13/// each channel's `schema_ref` from disk (relative to `base_dir`, or verbatim for
14/// absolute paths), parses the JSON Schema document, and stores it on the channel
15/// so the channel can later be built with a real schema. A missing, unreadable, or
16/// non-JSON schema file is an accumulated validation error like any other.
17///
18/// `base_dir` is the directory the config file was loaded from; relative
19/// `schema_ref` paths resolve against it. When it is `None` (e.g. a config
20/// assembled in memory), relative paths resolve against the process working
21/// directory, so callers that construct a config directly should use absolute
22/// `schema_ref` paths.
23///
24/// # Errors
25///
26/// Returns [`ServerError::ConfigValidation`] containing all discovered validation
27/// errors when the configuration is not safe to use for startup.
28pub 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
50/// Resolves, reads, and parses each channel's `schema_ref`, storing the loaded
51/// document on the channel. Follows the same deterministic-local-FS discipline as
52/// [`validate_persistence_path`]: every failure is accumulated rather than
53/// short-circuiting, so an operator sees all schema problems at once.
54fn 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
74/// Resolves a `schema_ref` to a concrete path: absolute refs are used verbatim,
75/// relative refs are joined onto `base_dir` (or the working directory when there
76/// is no base directory).
77fn resolve_schema_path(schema_ref: &Path, base_dir: Option<&Path>) -> PathBuf {
78    // `Path::join` returns `schema_ref` unchanged when it is absolute, so the
79    // base-dir arm covers both the absolute and relative cases.
80    base_dir.map_or_else(|| schema_ref.to_path_buf(), |dir| dir.join(schema_ref))
81}
82
83/// Reads, JSON-parses, and schema-compiles a schema file, returning the loaded
84/// document or a human-readable reason on failure (missing/unreadable file,
85/// invalid JSON, or valid JSON that is not a compilable JSON Schema). The
86/// compile check runs here so every schema problem surfaces in the accumulated
87/// validation pass instead of deferring to a different error class at channel
88/// construction.
89fn 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
268/// Validates the optional `[auth]` section. When present its token must be
269/// non-empty: an empty token would gate nothing (every client's empty `auth_token`
270/// would match), so it is rejected rather than silently leaving the server open.
271/// The token is not trimmed — a shared secret may legitimately contain leading or
272/// trailing whitespace.
273fn 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    /// Writes `contents` to a fresh uniquely-named temp file and returns its path.
507    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        // Valid JSON that is not a compilable JSON Schema: a schema document
595        // must be an object, so a bare array parses but fails compilation.
596        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}