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