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