Skip to main content

liminal_server/config/
env.rs

1use std::ffi::{OsStr, OsString};
2use std::net::SocketAddr;
3use std::path::PathBuf;
4
5use crate::ServerError;
6
7use super::types::ServerConfig;
8
9const ENV_PREFIX: &str = "LIMINAL_";
10const LISTEN_ADDRESS: &str = "LIMINAL_LISTEN_ADDRESS";
11const HEALTH_LISTEN_ADDRESS: &str = "LIMINAL_HEALTH_LISTEN_ADDRESS";
12const DRAIN_TIMEOUT_MS: &str = "LIMINAL_DRAIN_TIMEOUT_MS";
13const PERSISTENCE_PATH: &str = "LIMINAL_PERSISTENCE_PATH";
14const CLUSTER_NODE_NAME: &str = "LIMINAL_CLUSTER_NODE_NAME";
15const CLUSTER_SEED_NODES: &str = "LIMINAL_CLUSTER_SEED_NODES";
16const CLUSTER_LISTEN_ADDRESS: &str = "LIMINAL_CLUSTER_LISTEN_ADDRESS";
17const CLUSTER_COOKIE: &str = "LIMINAL_CLUSTER_COOKIE";
18const AUTH_TOKEN: &str = "LIMINAL_AUTH_TOKEN";
19const WEBSOCKET_LISTEN_ADDRESS: &str = "LIMINAL_WEBSOCKET_LISTEN_ADDRESS";
20const WEBSOCKET_PATH: &str = "LIMINAL_WEBSOCKET_PATH";
21const WEBSOCKET_ALLOWED_ORIGINS: &str = "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS";
22const WEBSOCKET_PING_INTERVAL_MS: &str = "LIMINAL_WEBSOCKET_PING_INTERVAL_MS";
23
24/// Applies supported `LIMINAL_` environment variable overrides to a config.
25///
26/// Absent variables leave the supplied configuration unchanged. Supported
27/// variables are `LIMINAL_LISTEN_ADDRESS`, `LIMINAL_EXTRA_LISTEN_ADDRESSES`,
28/// `LIMINAL_HEALTH_LISTEN_ADDRESS`,
29/// `LIMINAL_DRAIN_TIMEOUT_MS`, `LIMINAL_PERSISTENCE_PATH`,
30/// `LIMINAL_CLUSTER_NODE_NAME`, `LIMINAL_CLUSTER_SEED_NODES`,
31/// `LIMINAL_CLUSTER_LISTEN_ADDRESS`, `LIMINAL_CLUSTER_COOKIE`,
32/// `LIMINAL_AUTH_TOKEN`, `LIMINAL_WEBSOCKET_LISTEN_ADDRESS`,
33/// `LIMINAL_WEBSOCKET_PATH`, `LIMINAL_WEBSOCKET_ALLOWED_ORIGINS`, and
34/// `LIMINAL_WEBSOCKET_PING_INTERVAL_MS`.
35///
36/// Unlike the cluster and websocket overrides — which refuse to fabricate a
37/// `[cluster]`/`[websocket]` section that the file did not declare (a
38/// partially-specified section is unsafe) —
39/// `LIMINAL_AUTH_TOKEN` MAY create the `[auth]` section when the file omits it.
40/// The auth section is a single scalar secret with no other required fields, so a
41/// deployment can inject the token purely from the environment (the idiomatic way
42/// to supply a secret) without also pinning it in a committed config file; an empty
43/// value still fails the later non-empty validation, so this cannot silently create
44/// a no-op gate.
45///
46/// # Errors
47///
48/// Returns [`ServerError`] when a present environment variable cannot be parsed
49/// or targets cluster configuration that was not declared in the file.
50pub fn apply_env_overrides(config: ServerConfig) -> Result<ServerConfig, ServerError> {
51    apply_env_overrides_from(config, std::env::vars_os())
52}
53
54pub(crate) fn apply_env_overrides_from<I>(
55    mut config: ServerConfig,
56    variables: I,
57) -> Result<ServerConfig, ServerError>
58where
59    I: IntoIterator<Item = (OsString, OsString)>,
60{
61    for (key, value) in variables {
62        let Some(key) = key.to_str() else {
63            continue;
64        };
65
66        if !key.starts_with(ENV_PREFIX) {
67            continue;
68        }
69
70        match key {
71            LISTEN_ADDRESS => {
72                config.listen_address = parse_socket_addr(LISTEN_ADDRESS, &value)?;
73            }
74            super::extra_listen::ENV => {
75                config.extra_listen_addresses = super::extra_listen::parse(&value)?;
76            }
77            HEALTH_LISTEN_ADDRESS => {
78                config.health_listen_address = parse_socket_addr(HEALTH_LISTEN_ADDRESS, &value)?;
79            }
80            DRAIN_TIMEOUT_MS => {
81                config.drain_timeout_ms = parse_u64(DRAIN_TIMEOUT_MS, &value)?;
82            }
83            PERSISTENCE_PATH => {
84                config.persistence_path = Some(PathBuf::from(value));
85            }
86            CLUSTER_NODE_NAME => {
87                let node_name = env_string(CLUSTER_NODE_NAME, &value)?;
88                cluster_required(&mut config, CLUSTER_NODE_NAME)?.node_name = node_name;
89            }
90            CLUSTER_SEED_NODES => {
91                let seed_nodes = parse_seed_nodes(&value)?;
92                cluster_required(&mut config, CLUSTER_SEED_NODES)?.seed_nodes = seed_nodes;
93            }
94            CLUSTER_LISTEN_ADDRESS => {
95                let listen_address = parse_socket_addr(CLUSTER_LISTEN_ADDRESS, &value)?;
96                cluster_required(&mut config, CLUSTER_LISTEN_ADDRESS)?.listen_address =
97                    listen_address;
98            }
99            CLUSTER_COOKIE => {
100                let cookie = env_string(CLUSTER_COOKIE, &value)?;
101                cluster_required(&mut config, CLUSTER_COOKIE)?.cookie = cookie;
102            }
103            WEBSOCKET_LISTEN_ADDRESS => {
104                let listen_address = parse_socket_addr(WEBSOCKET_LISTEN_ADDRESS, &value)?;
105                websocket_required(&mut config, WEBSOCKET_LISTEN_ADDRESS)?.listen_address =
106                    listen_address;
107            }
108            WEBSOCKET_PATH => {
109                let path = env_string(WEBSOCKET_PATH, &value)?;
110                websocket_required(&mut config, WEBSOCKET_PATH)?.path = path;
111            }
112            WEBSOCKET_ALLOWED_ORIGINS => {
113                let allowed_origins = parse_allowed_origins(&value)?;
114                websocket_required(&mut config, WEBSOCKET_ALLOWED_ORIGINS)?.allowed_origins =
115                    allowed_origins;
116            }
117            WEBSOCKET_PING_INTERVAL_MS => {
118                let interval = parse_u64(WEBSOCKET_PING_INTERVAL_MS, &value)?;
119                websocket_required(&mut config, WEBSOCKET_PING_INTERVAL_MS)?.ping_interval_ms =
120                    Some(interval);
121            }
122            AUTH_TOKEN => {
123                // A single scalar secret is allowed to create the `[auth]` section
124                // even when the file omits it (see the module-level note); an empty
125                // value is left to fail the non-empty auth validation, not rejected
126                // here, so precedence and error reporting stay uniform.
127                let token = env_string(AUTH_TOKEN, &value)?;
128                config.auth = Some(super::types::AuthConfig {
129                    token,
130                    pass: None,
131                    lys: None,
132                    require_verified_worker: false,
133                });
134            }
135            unrecognized_key => {
136                // A LIMINAL_-prefixed key the server does not support is almost
137                // always a typo'd override; stay ignored (behaviour unchanged)
138                // but say so instead of silently dropping it.
139                tracing::warn!(
140                    key = %unrecognized_key,
141                    "unrecognized LIMINAL_ environment variable ignored; supported keys are unchanged"
142                );
143            }
144        }
145    }
146
147    Ok(config)
148}
149
150fn parse_socket_addr(name: &str, value: &OsStr) -> Result<SocketAddr, ServerError> {
151    let value = env_string(name, value)?;
152    value.parse::<SocketAddr>().map_err(|error| {
153        config_load(format!(
154            "environment variable {name} must be a socket address: {error}"
155        ))
156    })
157}
158
159fn parse_u64(name: &str, value: &OsStr) -> Result<u64, ServerError> {
160    let value = env_string(name, value)?;
161    value.parse::<u64>().map_err(|error| {
162        config_load(format!(
163            "environment variable {name} must be an unsigned integer: {error}"
164        ))
165    })
166}
167
168fn parse_seed_nodes(value: &OsStr) -> Result<Vec<SocketAddr>, ServerError> {
169    let value = env_string(CLUSTER_SEED_NODES, value)?;
170    if value.trim().is_empty() {
171        return Ok(Vec::new());
172    }
173
174    value
175        .split(',')
176        .enumerate()
177        .map(|(index, candidate)| parse_seed_node(index, candidate))
178        .collect()
179}
180
181fn parse_seed_node(index: usize, candidate: &str) -> Result<SocketAddr, ServerError> {
182    let candidate = candidate.trim();
183    if candidate.is_empty() {
184        return Err(config_load(format!(
185            "environment variable {CLUSTER_SEED_NODES} contains an empty seed node at position {}",
186            index + 1
187        )));
188    }
189
190    candidate.parse::<SocketAddr>().map_err(|error| {
191        config_load(format!(
192            "environment variable {CLUSTER_SEED_NODES} contains invalid seed node '{}' at position {}: {error}",
193            candidate,
194            index + 1
195        ))
196    })
197}
198
199fn env_string(name: &str, value: &OsStr) -> Result<String, ServerError> {
200    value.to_str().map(str::to_owned).ok_or_else(|| {
201        config_load(format!(
202            "environment variable {name} contains non-Unicode data"
203        ))
204    })
205}
206
207/// Parses the comma-separated `LIMINAL_WEBSOCKET_ALLOWED_ORIGINS` list. An
208/// empty value yields the empty (fail-closed) list; entries are trimmed and an
209/// empty entry between commas is a typed error rather than a silently dropped
210/// origin. Semantic origin-shape checks stay in config validation so file- and
211/// environment-sourced lists are held to the identical rules.
212fn parse_allowed_origins(value: &OsStr) -> Result<Vec<String>, ServerError> {
213    let value = env_string(WEBSOCKET_ALLOWED_ORIGINS, value)?;
214    if value.trim().is_empty() {
215        return Ok(Vec::new());
216    }
217    value
218        .split(',')
219        .enumerate()
220        .map(|(index, candidate)| {
221            let candidate = candidate.trim();
222            if candidate.is_empty() {
223                Err(config_load(format!(
224                    "environment variable {WEBSOCKET_ALLOWED_ORIGINS} contains an empty origin \
225                     at position {}",
226                    index + 1
227                )))
228            } else {
229                Ok(candidate.to_owned())
230            }
231        })
232        .collect()
233}
234
235/// Mirrors [`cluster_required`]: the `[websocket]` section has two required
236/// fields with no defaults, so the environment may adjust a declared section but
237/// must never fabricate a partially-specified acceptor.
238fn websocket_required<'a>(
239    config: &'a mut ServerConfig,
240    name: &str,
241) -> Result<&'a mut super::types::WebSocketConfig, ServerError> {
242    config
243        .websocket
244        .as_mut()
245        .ok_or_else(|| ServerError::ConfigValidation {
246            message: format!(
247                "environment variable {name} requires a [websocket] section in the configuration \
248                 file"
249            ),
250        })
251}
252
253fn cluster_required<'a>(
254    config: &'a mut ServerConfig,
255    name: &str,
256) -> Result<&'a mut super::types::ClusterConfig, ServerError> {
257    config
258        .cluster
259        .as_mut()
260        .ok_or_else(|| ServerError::ConfigValidation {
261            message: format!(
262                "environment variable {name} requires a [cluster] section in the configuration file"
263            ),
264        })
265}
266
267const fn config_load(message: String) -> ServerError {
268    ServerError::ConfigLoad { message }
269}
270
271#[cfg(test)]
272mod tests {
273    use std::ffi::OsString;
274    use std::net::SocketAddr;
275    use std::path::{Path, PathBuf};
276
277    use crate::ServerError;
278
279    use super::apply_env_overrides_from;
280    use crate::config::types::{ChannelDef, ClusterConfig, RoutingRuleDef, ServerConfig};
281    use crate::config::{load_from_file, validate};
282
283    fn socket(address: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
284        Ok(address.parse()?)
285    }
286
287    fn sample_config() -> Result<ServerConfig, Box<dyn std::error::Error>> {
288        Ok(ServerConfig {
289            listen_address: socket("127.0.0.1:8080")?,
290            extra_listen_addresses: Vec::new(),
291            health_listen_address: socket("127.0.0.1:8081")?,
292            drain_timeout_ms: 30_000,
293            channels: vec![ChannelDef {
294                name: "orders".to_owned(),
295                schema_ref: None,
296                durable: true,
297                loaded_schema: None,
298            }],
299            routing_rules: vec![RoutingRuleDef {
300                source_channel: "orders".to_owned(),
301                target_channel: "orders".to_owned(),
302                predicate: None,
303            }],
304            persistence_path: Some(PathBuf::from("/tmp")),
305            cluster: Some(ClusterConfig {
306                node_name: "node-a".to_owned(),
307                listen_address: socket("127.0.0.1:9000")?,
308                seed_nodes: vec![socket("127.0.0.1:9001")?],
309                cookie: "test-cookie".to_owned(),
310            }),
311            auth: None,
312            services: crate::config::types::ServicesConfig::default(),
313            limits: crate::config::types::LimitsConfig::default(),
314            participant: None,
315            websocket: None,
316            open: None,
317            templates: crate::services::lifecycle::templates::TemplateSources::default(),
318            agents: None,
319        })
320    }
321
322    fn env_pair(name: &str, value: &str) -> (OsString, OsString) {
323        (OsString::from(name), OsString::from(value))
324    }
325
326    fn write_temp_config(contents: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
327        let path = std::env::temp_dir().join(format!(
328            "liminal-server-env-pipeline-{}.toml",
329            std::process::id()
330        ));
331        std::fs::write(&path, contents)?;
332        Ok(path)
333    }
334
335    fn remove_temp_file(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
336        if path.exists() {
337            std::fs::remove_file(path)?;
338        }
339        Ok(())
340    }
341
342    #[test]
343    fn listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
344        let config = sample_config()?;
345        let config = apply_env_overrides_from(
346            config,
347            vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
348        )?;
349
350        assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
351
352        Ok(())
353    }
354
355    #[test]
356    fn health_listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>>
357    {
358        let config = sample_config()?;
359        let config = apply_env_overrides_from(
360            config,
361            vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "0.0.0.0:9191")],
362        )?;
363
364        assert_eq!(config.health_listen_address, socket("0.0.0.0:9191")?);
365
366        Ok(())
367    }
368
369    #[test]
370    fn drain_timeout_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
371        let config = sample_config()?;
372        let config =
373            apply_env_overrides_from(config, vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "1250")])?;
374
375        assert_eq!(config.drain_timeout_ms, 1250);
376
377        Ok(())
378    }
379
380    #[test]
381    fn persistence_path_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
382        let config = sample_config()?;
383        let config = apply_env_overrides_from(
384            config,
385            vec![env_pair("LIMINAL_PERSISTENCE_PATH", "/var/lib/liminal")],
386        )?;
387
388        assert_eq!(
389            config.persistence_path.as_deref(),
390            Some(Path::new("/var/lib/liminal"))
391        );
392
393        Ok(())
394    }
395
396    #[test]
397    fn cluster_overrides_replace_existing_cluster_values() -> Result<(), Box<dyn std::error::Error>>
398    {
399        let config = sample_config()?;
400        let config = apply_env_overrides_from(
401            config,
402            vec![
403                env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b"),
404                env_pair(
405                    "LIMINAL_CLUSTER_SEED_NODES",
406                    "127.0.0.1:9100, 127.0.0.1:9200",
407                ),
408            ],
409        )?;
410
411        let Some(cluster) = config.cluster else {
412            return Err("cluster config should remain present".into());
413        };
414        assert_eq!(cluster.node_name, "node-b");
415        assert_eq!(cluster.seed_nodes.len(), 2);
416        assert_eq!(cluster.seed_nodes[0], socket("127.0.0.1:9100")?);
417        assert_eq!(cluster.seed_nodes[1], socket("127.0.0.1:9200")?);
418
419        Ok(())
420    }
421
422    #[test]
423    fn cluster_listen_address_and_cookie_overrides_replace_values()
424    -> Result<(), Box<dyn std::error::Error>> {
425        let config = sample_config()?;
426        let config = apply_env_overrides_from(
427            config,
428            vec![
429                env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500"),
430                env_pair("LIMINAL_CLUSTER_COOKIE", "override-cookie"),
431            ],
432        )?;
433
434        let Some(cluster) = config.cluster else {
435            return Err("cluster config should remain present".into());
436        };
437        assert_eq!(cluster.listen_address, socket("127.0.0.1:9500")?);
438        assert_eq!(cluster.cookie, "override-cookie");
439
440        Ok(())
441    }
442
443    #[test]
444    fn cluster_listen_address_override_without_cluster_section_returns_validation_error()
445    -> Result<(), Box<dyn std::error::Error>> {
446        let mut config = sample_config()?;
447        config.cluster = None;
448        let result = apply_env_overrides_from(
449            config,
450            vec![env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500")],
451        );
452
453        assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
454
455        Ok(())
456    }
457
458    #[test]
459    fn auth_token_override_replaces_existing_token() -> Result<(), Box<dyn std::error::Error>> {
460        let mut config = sample_config()?;
461        config.auth = Some(crate::config::types::AuthConfig {
462            pass: None,
463            token: "file-token".to_owned(),
464            require_verified_worker: false,
465            lys: None,
466        });
467
468        let config =
469            apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
470
471        let auth = config.auth.ok_or("auth section should remain present")?;
472        assert_eq!(auth.token, "env-token");
473
474        Ok(())
475    }
476
477    #[test]
478    fn auth_token_override_creates_missing_auth_section() -> Result<(), Box<dyn std::error::Error>>
479    {
480        let mut config = sample_config()?;
481        config.auth = None;
482
483        let config =
484            apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
485
486        // Unlike cluster overrides, a scalar auth secret MAY fabricate the section.
487        let auth = config.auth.ok_or("auth section should have been created")?;
488        assert_eq!(auth.token, "env-token");
489
490        Ok(())
491    }
492
493    #[test]
494    fn absent_environment_variables_leave_config_unchanged()
495    -> Result<(), Box<dyn std::error::Error>> {
496        let config = sample_config()?;
497        let original_address = config.listen_address;
498        let original_health_address = config.health_listen_address;
499        let original_drain_timeout_ms = config.drain_timeout_ms;
500        let original_path = config.persistence_path.clone();
501        let original_cluster_name = config
502            .cluster
503            .as_ref()
504            .map(|cluster| cluster.node_name.clone());
505
506        let config = apply_env_overrides_from(config, Vec::new())?;
507
508        assert_eq!(config.listen_address, original_address);
509        assert_eq!(config.health_listen_address, original_health_address);
510        assert_eq!(config.drain_timeout_ms, original_drain_timeout_ms);
511        assert_eq!(config.persistence_path, original_path);
512        assert_eq!(
513            config
514                .cluster
515                .as_ref()
516                .map(|cluster| cluster.node_name.clone()),
517            original_cluster_name
518        );
519
520        Ok(())
521    }
522
523    #[test]
524    fn unrecognized_liminal_key_is_ignored_but_warned() -> Result<(), Box<dyn std::error::Error>> {
525        let config = sample_config()?;
526        let original_address = config.listen_address;
527
528        let log = crate::test_log::CapturedLog::default();
529        // Typo'd key: `LIMINAL_LISTEN_ADDR`, not the supported `..._ADDRESS`.
530        let config = log.capture(|| {
531            apply_env_overrides_from(config, vec![env_pair("LIMINAL_LISTEN_ADDR", "x")])
532        })?;
533
534        // Behaviour unchanged: the override is ignored, the config still Ok.
535        assert_eq!(config.listen_address, original_address);
536
537        let output = log.contents();
538        assert!(
539            output.contains("WARN"),
540            "an unrecognized LIMINAL_ key must be warned about, got: {output}"
541        );
542        assert!(
543            output.contains("LIMINAL_LISTEN_ADDR"),
544            "the warn must name the unrecognized key, got: {output}"
545        );
546
547        Ok(())
548    }
549
550    #[test]
551    fn invalid_listen_address_override_returns_config_load()
552    -> Result<(), Box<dyn std::error::Error>> {
553        let config = sample_config()?;
554        let result = apply_env_overrides_from(
555            config,
556            vec![env_pair("LIMINAL_LISTEN_ADDRESS", "not-a-socket")],
557        );
558
559        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
560
561        Ok(())
562    }
563
564    #[test]
565    fn invalid_health_listen_address_override_returns_config_load()
566    -> Result<(), Box<dyn std::error::Error>> {
567        let config = sample_config()?;
568        let result = apply_env_overrides_from(
569            config,
570            vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "not-a-socket")],
571        );
572
573        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
574
575        Ok(())
576    }
577
578    #[test]
579    fn invalid_drain_timeout_override_returns_config_load() -> Result<(), Box<dyn std::error::Error>>
580    {
581        let config = sample_config()?;
582        let result = apply_env_overrides_from(
583            config,
584            vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "not-a-number")],
585        );
586
587        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
588
589        Ok(())
590    }
591
592    #[test]
593    fn cluster_override_without_cluster_section_returns_validation_error()
594    -> Result<(), Box<dyn std::error::Error>> {
595        let mut config = sample_config()?;
596        config.cluster = None;
597        let result = apply_env_overrides_from(
598            config,
599            vec![env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b")],
600        );
601
602        assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
603
604        Ok(())
605    }
606
607    #[test]
608    fn file_then_env_then_validate_pipeline_gives_env_precedence()
609    -> Result<(), Box<dyn std::error::Error>> {
610        // gated by a bearer: an exchange that listens off loopback has [auth]
611        // (ACCESS-010 R1); this pin is about the environment's precedence
612        let toml = r#"
613listen_address = "127.0.0.1:8080"
614health_listen_address = "127.0.0.1:8081"
615drain_timeout_ms = 30000
616persistence_path = "/tmp"
617
618[auth]
619token = "SYNTHETIC-BEARER-FOR-THE-ENV-PRECEDENCE-PIN"
620
621[[channels]]
622name = "orders"
623durable = true
624
625[[routing_rules]]
626source_channel = "orders"
627target_channel = "orders"
628"#;
629        let path = write_temp_config(toml)?;
630        let config = load_from_file(&path)?;
631        let mut config = apply_env_overrides_from(
632            config,
633            vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
634        )?;
635        validate(&mut config, path.parent())?;
636        remove_temp_file(&path)?;
637
638        assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
639
640        Ok(())
641    }
642
643    // ---- LP-WS-TRANSPORT R1.1: [websocket] environment overrides ----
644
645    fn sample_config_with_websocket() -> Result<ServerConfig, Box<dyn std::error::Error>> {
646        let mut config = sample_config()?;
647        config.websocket = Some(crate::config::types::WebSocketConfig {
648            listen_address: socket("127.0.0.1:8082")?,
649            path: "/liminal".to_owned(),
650            allowed_origins: Vec::new(),
651            allowed_origin_ports: Vec::new(),
652            ping_interval_ms: None,
653        });
654        Ok(config)
655    }
656
657    #[test]
658    fn websocket_overrides_replace_declared_section_values()
659    -> Result<(), Box<dyn std::error::Error>> {
660        let config = sample_config_with_websocket()?;
661        let config = apply_env_overrides_from(
662            config,
663            vec![
664                env_pair("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
665                env_pair("LIMINAL_WEBSOCKET_PATH", "/bridge"),
666                env_pair(
667                    "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
668                    "https://a.example.com, https://b.example.com",
669                ),
670                env_pair("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
671            ],
672        )?;
673        let websocket = config.websocket.ok_or("websocket section missing")?;
674        assert_eq!(websocket.listen_address, socket("0.0.0.0:9292")?);
675        assert_eq!(websocket.path, "/bridge");
676        assert_eq!(
677            websocket.allowed_origins,
678            vec![
679                "https://a.example.com".to_owned(),
680                "https://b.example.com".to_owned()
681            ]
682        );
683        assert_eq!(websocket.ping_interval_ms, Some(15_000));
684        Ok(())
685    }
686
687    #[test]
688    fn websocket_override_without_declared_section_is_refused()
689    -> Result<(), Box<dyn std::error::Error>> {
690        for (name, value) in [
691            ("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
692            ("LIMINAL_WEBSOCKET_PATH", "/bridge"),
693            ("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "https://a.example.com"),
694            ("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
695        ] {
696            let config = sample_config()?;
697            let result = apply_env_overrides_from(config, vec![env_pair(name, value)]);
698            let Err(ServerError::ConfigValidation { message }) = result else {
699                return Err(format!("{name}: fabricating [websocket] must be refused").into());
700            };
701            assert!(
702                message.contains("[websocket]"),
703                "{name}: expected a section-required error, got: {message}"
704            );
705        }
706        Ok(())
707    }
708
709    #[test]
710    fn websocket_empty_origin_list_override_is_fail_closed()
711    -> Result<(), Box<dyn std::error::Error>> {
712        let config = sample_config_with_websocket()?;
713        let config = apply_env_overrides_from(
714            config,
715            vec![env_pair("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "")],
716        )?;
717        let websocket = config.websocket.ok_or("websocket section missing")?;
718        assert!(websocket.allowed_origins.is_empty());
719        Ok(())
720    }
721
722    #[test]
723    fn websocket_origin_list_with_empty_entry_is_refused() -> Result<(), Box<dyn std::error::Error>>
724    {
725        let config = sample_config_with_websocket()?;
726        let result = apply_env_overrides_from(
727            config,
728            vec![env_pair(
729                "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
730                "https://a.example.com,,https://b.example.com",
731            )],
732        );
733        let Err(ServerError::ConfigLoad { message }) = result else {
734            return Err("an empty origin entry must be a typed load error".into());
735        };
736        assert!(
737            message.contains("empty origin"),
738            "expected an empty-origin error, got: {message}"
739        );
740        Ok(())
741    }
742}