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