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, ServiceProfile};
7
8mod links;
9mod profiles;
10mod websocket;
11use links::validate_outbound_links;
12use profiles::validate_services;
13pub(crate) use profiles::worker_front_door_field_errors;
14use websocket::validate_websocket;
15
16#[cfg(test)]
17mod link_export_tests;
18
19/// Validates a fully loaded server configuration before startup.
20///
21/// Validation is intentionally limited to deterministic semantic checks and
22/// filesystem inspection. It does not bind sockets, connect to peers, or perform
23/// any other network I/O. Beyond the semantic checks it also resolves and loads
24/// each channel's `schema_ref` from disk (relative to `base_dir`, or verbatim for
25/// absolute paths), parses the JSON Schema document, and stores it on the channel
26/// so the channel can later be built with a real schema. A missing, unreadable, or
27/// non-JSON schema file is an accumulated validation error like any other.
28/// Link exports refuse missing placed subjects, non-durable channels, and the reserved `liminal.` namespace by link and prefix before startup.
29///
30/// `base_dir` is the directory the config file was loaded from; relative
31/// `schema_ref` paths resolve against it. When it is `None` (e.g. a config
32/// assembled in memory), relative paths resolve against the process working
33/// directory, so callers that construct a config directly should use absolute
34/// `schema_ref` paths.
35///
36/// # Errors
37///
38/// Returns [`ServerError::ConfigValidation`] containing all discovered validation
39/// errors when the configuration is not safe to use for startup.
40pub fn validate(config: &mut ServerConfig, base_dir: Option<&Path>) -> Result<(), ServerError> {
41    let mut errors = Vec::new();
42
43    validate_listen_address(config, &mut errors);
44    super::extra_listen::collect_errors(config, &mut errors);
45    validate_health_listen_address(config, &mut errors);
46    // NO `validate_drain_timeout`. `drain_timeout_ms` has been ignored since
47    // 0.14.3 (see `ServerConfig::drain_timeout_ms`), and a refusal is an effect:
48    // a field that changes nothing must not be able to stop a startup.
49    validate_channels(config, &mut errors);
50    validate_routing_rules(config, &mut errors);
51    validate_persistence_path(config, &mut errors);
52    validate_cluster(config, &mut errors);
53    validate_auth(config, &mut errors);
54    if let Some(lys) = config.auth.as_ref().and_then(|auth| auth.lys.as_ref()) {
55        crate::services::broker::validate_configuration(lys, &mut errors);
56        crate::link_pass::validate_configuration(lys, &mut errors);
57    }
58    validate_outbound_links(config, &mut errors);
59    super::templates::validate_templates(config, &mut errors);
60    validate_services(config, &mut errors);
61    validate_websocket(config, &mut errors);
62    config.limits.collect_errors(&mut errors);
63    validate_participant(config, &mut errors);
64    load_channel_schemas(config, base_dir, &mut errors);
65
66    if errors.is_empty() {
67        Ok(())
68    } else {
69        Err(ServerError::ConfigValidation {
70            message: errors.join("\n"),
71        })
72    }
73}
74
75/// Resolves, reads, and parses each channel's `schema_ref`, storing the loaded
76/// document on the channel. Follows the same deterministic-local-FS discipline as
77/// [`validate_persistence_path`]: every failure is accumulated rather than
78/// short-circuiting, so an operator sees all schema problems at once.
79fn load_channel_schemas(
80    config: &mut ServerConfig,
81    base_dir: Option<&Path>,
82    errors: &mut Vec<String>,
83) {
84    for channel in &mut config.channels {
85        let Some(schema_ref) = channel.schema_ref.as_ref() else {
86            continue;
87        };
88        let path = resolve_schema_path(schema_ref, base_dir);
89        match load_schema_document(&path) {
90            Ok(loaded) => channel.loaded_schema = Some(loaded),
91            Err(reason) => errors.push(format!(
92                "channels.schema_ref '{}': {reason}",
93                schema_ref.display()
94            )),
95        }
96    }
97}
98
99/// Resolves a `schema_ref` to a concrete path: absolute refs are used verbatim,
100/// relative refs are joined onto `base_dir` (or the working directory when there
101/// is no base directory).
102fn resolve_schema_path(schema_ref: &Path, base_dir: Option<&Path>) -> PathBuf {
103    // `Path::join` returns `schema_ref` unchanged when it is absolute, so the
104    // base-dir arm covers both the absolute and relative cases.
105    base_dir.map_or_else(|| schema_ref.to_path_buf(), |dir| dir.join(schema_ref))
106}
107
108/// Reads, JSON-parses, and schema-compiles a schema file, returning the loaded
109/// document or a human-readable reason on failure (missing/unreadable file,
110/// invalid JSON, or valid JSON that is not a compilable JSON Schema). The
111/// compile check runs here so every schema problem surfaces in the accumulated
112/// validation pass instead of deferring to a different error class at channel
113/// construction.
114fn load_schema_document(path: &Path) -> Result<LoadedSchema, String> {
115    let bytes = std::fs::read(path)
116        .map_err(|error| format!("schema file '{}' is unreadable: {error}", path.display()))?;
117    let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
118        format!(
119            "schema file '{}' is not valid JSON: {error}",
120            path.display()
121        )
122    })?;
123    liminal::channel::Schema::new(document.clone()).map_err(|error| {
124        format!(
125            "schema file '{}' is not a valid JSON Schema: {error}",
126            path.display()
127        )
128    })?;
129    Ok(LoadedSchema { bytes, document })
130}
131
132fn validate_listen_address(config: &ServerConfig, errors: &mut Vec<String>) {
133    if config.listen_address.port() == 0 {
134        errors.push("listen_address: port must be non-zero".to_owned());
135    }
136}
137
138fn validate_health_listen_address(config: &ServerConfig, errors: &mut Vec<String>) {
139    if config.health_listen_address.port() == 0 {
140        errors.push("health_listen_address: port must be non-zero".to_owned());
141    }
142
143    if config.health_listen_address == config.listen_address {
144        errors.push(
145            "health_listen_address: must differ from listen_address for probe isolation".to_owned(),
146        );
147    } else if config.health_listen_address.port() == config.listen_address.port() {
148        errors.push(
149            "health_listen_address: port must differ from listen_address port for probe isolation"
150                .to_owned(),
151        );
152    }
153}
154
155fn validate_channels(config: &ServerConfig, errors: &mut Vec<String>) {
156    let mut seen = BTreeSet::new();
157    let mut duplicates = BTreeSet::new();
158
159    for channel in &config.channels {
160        let name = channel.name.trim();
161        if name.is_empty() {
162            errors.push("channels.name: channel name must not be empty".to_owned());
163            continue;
164        }
165
166        if !seen.insert(name.to_owned()) {
167            duplicates.insert(name.to_owned());
168        }
169
170        // SRV-012 R4: the console's channels are ephemeral and server-owned;
171        // configured durable they would write the store.
172        if channel.durable && crate::server::connection::is_console_subject(name) {
173            errors.push(format!(
174                "channels.durable: console channel '{name}' must not be durable"
175            ));
176        }
177    }
178
179    if !duplicates.is_empty() {
180        let names = duplicates.into_iter().collect::<Vec<_>>().join(", ");
181        errors.push(format!("channels.name: duplicate channel names: {names}"));
182    }
183}
184
185fn validate_routing_rules(config: &ServerConfig, errors: &mut Vec<String>) {
186    let channel_names = config
187        .channels
188        .iter()
189        .map(|channel| channel.name.as_str())
190        .collect::<BTreeSet<_>>();
191
192    for (index, rule) in config.routing_rules.iter().enumerate() {
193        let source = rule.source_channel.trim();
194        if source.is_empty() {
195            errors.push(format!(
196                "routing_rules[{index}].source_channel: source channel must not be empty"
197            ));
198        } else if !channel_names.contains(source) {
199            errors.push(format!(
200                "routing_rules[{index}].source_channel: unknown channel '{source}'"
201            ));
202        }
203
204        let target = rule.target_channel.trim();
205        if target.is_empty() {
206            errors.push(format!(
207                "routing_rules[{index}].target_channel: target channel must not be empty"
208            ));
209        } else if !channel_names.contains(target) {
210            errors.push(format!(
211                "routing_rules[{index}].target_channel: unknown channel '{target}'"
212            ));
213        }
214    }
215}
216
217pub(crate) const MISSING_PERSISTENCE_PATH: &str = "persistence_path: full services require an existing directory; set persistence_path or LIMINAL_PERSISTENCE_PATH; no store is opened";
218
219pub(crate) fn required_persistence_path(config: &ServerConfig) -> Result<&Path, ServerError> {
220    config
221        .persistence_path
222        .as_deref()
223        .ok_or_else(|| ServerError::ConfigValidation {
224            message: MISSING_PERSISTENCE_PATH.to_owned(),
225        })
226}
227
228fn validate_persistence_path(config: &ServerConfig, errors: &mut Vec<String>) {
229    let Some(path) = config.persistence_path.as_deref() else {
230        if matches!(config.services.profile(), Ok(ServiceProfile::Full)) {
231            errors.push(MISSING_PERSISTENCE_PATH.to_owned());
232        }
233        return;
234    };
235
236    match std::fs::metadata(path) {
237        Ok(metadata) => {
238            if !metadata.is_dir() {
239                errors.push(format!(
240                    "persistence_path '{}': path must be an existing directory",
241                    path.display()
242                ));
243            } else if metadata.permissions().readonly() {
244                errors.push(format!(
245                    "persistence_path '{}': path is not writable",
246                    path.display()
247                ));
248            }
249        }
250        Err(error) => {
251            errors.push(format!(
252                "persistence_path '{}': path is unreachable: {error}",
253                path.display()
254            ));
255        }
256    }
257}
258
259fn validate_cluster(config: &ServerConfig, errors: &mut Vec<String>) {
260    let Some(cluster) = config.cluster.as_ref() else {
261        return;
262    };
263
264    if cluster.node_name.trim().is_empty() {
265        errors.push("cluster.node_name: node name must not be empty".to_owned());
266    }
267
268    if cluster.cookie.is_empty() {
269        errors.push("cluster.cookie: distribution cookie must not be empty".to_owned());
270    } else if cluster.cookie == super::types::DEFAULT_COOKIE {
271        errors.push(
272            "cluster.cookie: secret cookie must be set; the public default is refused".to_owned(),
273        );
274    }
275
276    if cluster.listen_address.port() == 0 {
277        errors.push("cluster.listen_address: distribution port must be non-zero".to_owned());
278    }
279
280    if cluster.listen_address == config.listen_address {
281        errors.push(
282            "cluster.listen_address: distribution port must differ from the client listen_address"
283                .to_owned(),
284        );
285    }
286
287    let mut seed_node_counts = BTreeMap::new();
288    for (index, seed_node) in cluster.seed_nodes.iter().enumerate() {
289        if seed_node.port() == 0 {
290            errors.push(format!(
291                "cluster.seed_nodes[{index}]: seed node port must be non-zero"
292            ));
293        }
294        seed_node_counts
295            .entry(seed_node.to_string())
296            .and_modify(|count| *count += 1)
297            .or_insert(1_usize);
298    }
299
300    let duplicates = seed_node_counts
301        .into_iter()
302        .filter_map(|(seed_node, count)| (count > 1).then_some(seed_node))
303        .collect::<Vec<_>>();
304
305    if !duplicates.is_empty() {
306        errors.push(format!(
307            "cluster.seed_nodes: duplicate seed nodes: {}",
308            duplicates.join(", ")
309        ));
310    }
311}
312
313/// Validates the optional `[auth]` section. When present its token must be
314/// non-empty: an empty token would gate nothing (every client's empty `auth_token`
315/// would match), so it is rejected rather than silently leaving the server open.
316/// The token is not trimmed — a shared secret may legitimately contain leading or
317/// trailing whitespace.
318fn validate_auth(config: &ServerConfig, errors: &mut Vec<String>) {
319    let Some(auth) = config.auth.as_ref() else {
320        return;
321    };
322
323    if auth.token.is_empty() {
324        errors.push("auth.token: authentication token must not be empty".to_owned());
325    }
326    // A token longer than a pre-admission `Connect` can carry could never be
327    // presented: every client would be refused before its token was compared.
328    let max = crate::server::connection::pre_admission::MAX_CONNECT_AUTH_TOKEN_BYTES;
329    if auth.token.len() > max {
330        errors.push(format!(
331            "auth.token: authentication token is {} bytes; a Connect can carry at most {max}",
332            auth.token.len()
333        ));
334    }
335    // ACCESS-004 R3: only a pass proves a holder, so requiring one without a
336    // pass verifier would refuse every worker.
337    if auth.require_verified_worker && auth.pass.is_none() {
338        errors.push(
339            "auth.require_verified_worker: requires [auth.pass], or no worker could register"
340                .to_owned(),
341        );
342    }
343}
344
345/// Semantic checks for the optional `[participant]` section: the shared
346/// nonzero/ordering rules plus the protocol codec's own minimum-frame check on
347/// `wire_frame_limit`, so an impossible limit fails at validation rather than
348/// at service construction.
349fn validate_participant(config: &ServerConfig, errors: &mut Vec<String>) {
350    let Some(participant) = config.participant.as_ref() else {
351        return;
352    };
353    participant.collect_errors(errors);
354    if participant.wire_frame_limit != 0
355        && let Err(error) = crate::server::participant::normalize_configured_frame_limit(
356            participant.wire_frame_limit,
357        )
358    {
359        errors.push(format!(
360            "participant.wire_frame_limit: {} is below the protocol's minimum complete \
361             participant frame ({error:?})",
362            participant.wire_frame_limit
363        ));
364    }
365}
366
367#[cfg(test)]
368mod self_stream_tests;
369#[cfg(test)]
370mod tests;