Skip to main content

zerodds_config/
layers.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The two layers around the files: the environment beneath them and the
4//! command line above them.
5//!
6//! The precedence every ZeroDDS program uses, lowest first:
7//!
8//! 1. compiled-in defaults,
9//! 2. the environment, under the names a consumer's table lists,
10//! 3. the files — base, drop-in directories, includes, in [`crate::Loader`]
11//!    order,
12//! 4. `--set path=value` on the command line, in the order written.
13//!
14//! `--config PATH` does not add a layer. It selects which base file layer 3
15//! starts from; without it, layer 3 is the [`SystemLayout`] of the platform.
16//! `--check-config` loads all four exactly as a start would, and the program
17//! reports instead of running.
18
19extern crate alloc;
20
21use alloc::collections::BTreeMap;
22use alloc::string::{String, ToString};
23use alloc::vec::Vec;
24use std::path::PathBuf;
25
26use crate::{Config, ConfigError, Node, Origin};
27
28/// The source name the environment layer carries in [`Config::sources`].
29pub const ENV_SOURCE: &str = "environment";
30
31/// The source name the command-line layer carries in [`Config::sources`].
32pub const CLI_SOURCE: &str = "command line";
33
34/// A tree holding one value at a dotted path.
35///
36/// A last segment written `+name` appends when the tree is merged, the same
37/// spelling a drop-in file uses.
38///
39/// # Errors
40/// [`ConfigError::Usage`] for an empty path or an empty segment — `a..b`,
41/// `.a`, `a.` name nothing, and reading them as something would be a guess.
42pub fn path_node(path: &str, value: &str, origin: Origin) -> Result<Node, ConfigError> {
43    at_path(path, Node::Scalar(value.to_string(), origin))
44}
45
46/// `leaf` at `path`, under the rules of [`path_node`].
47fn at_path(path: &str, leaf: Node) -> Result<Node, ConfigError> {
48    let segments: Vec<&str> = path.split('.').collect();
49    if segments.iter().any(|s| s.is_empty() || *s == "+") {
50        return Err(ConfigError::Usage(alloc::format!(
51            "`{path}` is not a dotted setting path"
52        )));
53    }
54    if segments[..segments.len() - 1]
55        .iter()
56        .any(|s| s.starts_with('+'))
57    {
58        return Err(ConfigError::Usage(alloc::format!(
59            "`{path}`: only the last segment may be written `+name`"
60        )));
61    }
62    let mut node = leaf;
63    for segment in segments.iter().rev() {
64        let mut map = BTreeMap::new();
65        map.insert((*segment).to_string(), node);
66        node = Node::Map(map);
67    }
68    Ok(node)
69}
70
71/// A `--set` path as the tree spells it: `+discovery.peers`, the spelling
72/// an operator reaches for, is `discovery.+peers` there.
73fn tree_path(path: &str) -> String {
74    let path = path.trim();
75    match path.strip_prefix('+') {
76        Some(rest) => match rest.rsplit_once('.') {
77            Some((head, last)) => alloc::format!("{head}.+{last}"),
78            None => alloc::format!("+{rest}"),
79        },
80        None => path.to_string(),
81    }
82}
83
84/// The environment layer: one value per variable of `table` that `lookup`
85/// finds, at the path the table gives it.
86///
87/// `table` pairs a variable name with a dotted path, the way the runtime's
88/// `SETTINGS` does. `lookup` is the environment — `std::env::var` in a
89/// program, a map in a test. An empty value counts as set: `FOO=` is how an
90/// operator clears a list.
91///
92/// A path `is_list` accepts becomes a sequence, split at commas, so that a
93/// drop-in's `+name` appends to what the environment set instead of meeting
94/// a scalar. An empty value is the empty sequence.
95///
96/// # Errors
97/// [`ConfigError::Usage`] when a table path is not a dotted path, which is a
98/// defect in the table rather than in the environment.
99pub fn env_layer<F, L>(table: &[(&str, &str)], lookup: F, is_list: L) -> Result<Node, ConfigError>
100where
101    F: Fn(&str) -> Option<String>,
102    L: Fn(&str) -> bool,
103{
104    let mut root = Node::Map(BTreeMap::new());
105    for &(name, path) in table {
106        let Some(value) = lookup(name) else {
107            continue;
108        };
109        let origin = Origin::Env(name.to_string());
110        let mut node = path_node(path, &value, origin.clone())?;
111        if is_list(path) {
112            let items = value
113                .split(',')
114                .map(str::trim)
115                .filter(|v| !v.is_empty())
116                .map(|v| Node::Scalar(v.to_string(), origin.clone()))
117                .collect();
118            set_leaf(&mut node, Node::Seq(items));
119        }
120        root.merge(node)?;
121    }
122    Ok(root)
123}
124
125/// Replaces the single leaf of a tree [`path_node`] built.
126fn set_leaf(node: &mut Node, leaf: Node) {
127    let mut here = node;
128    loop {
129        match here {
130            Node::Map(map) => match map.values_mut().next() {
131                Some(next) => here = next,
132                None => return,
133            },
134            slot => {
135                *slot = leaf;
136                return;
137            }
138        }
139    }
140}
141
142/// What the configuration flags on a command line said.
143///
144/// Every ZeroDDS program reads the same two flags, so they are parsed once
145/// here and the program sees only the arguments that are its own:
146///
147/// - `--config PATH` / `--config=PATH` selects the base file. Given twice, it
148///   is refused: which of the two was meant is not the parser's to decide.
149/// - `--set PATH=VALUE` / `--set=PATH=VALUE` sets one value above every file.
150///   Repeatable; a later one wins, `--set +discovery.peers=…` appends.
151///
152/// - `--check-config` asks the program to validate and report the effective
153///   configuration and exit, without starting.
154///
155/// Parsing stops at `--`; what follows it belongs to the program.
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct CommandLine {
158    config: Option<PathBuf>,
159    sets: Vec<(String, String)>,
160    entries: Vec<Entry>,
161    check: bool,
162}
163
164/// One map a program's own flag appends to a list, see
165/// [`CommandLine::push_entry`].
166#[derive(Debug, Clone, PartialEq, Eq)]
167struct Entry {
168    path: String,
169    fields: Vec<(String, String)>,
170    flag: String,
171}
172
173impl CommandLine {
174    /// Takes the configuration flags out of `args` and returns the rest in
175    /// their original order.
176    ///
177    /// A long option in the rest written `--opt=value` comes back as `--opt`
178    /// and `value`, so every program accepts both spellings the man pages use
179    /// (before, amqp-, grpc- and corba-bridged and `zerodds-ros2-shim`
180    /// answered `--domain=3` with "unknown argument", measured). What follows
181    /// a `--` is passed on untouched.
182    ///
183    /// `args` excludes the program name.
184    ///
185    /// # Errors
186    /// [`ConfigError::Usage`] for a flag without its value, a `--set` without
187    /// `=`, an empty path, or `--config` given twice.
188    pub fn extract<I>(args: I) -> Result<(Self, Vec<String>), ConfigError>
189    where
190        I: IntoIterator<Item = String>,
191    {
192        let mut out = Self::default();
193        let mut rest = Vec::new();
194        let mut args = args.into_iter();
195        while let Some(arg) = args.next() {
196            if arg == "--" {
197                rest.push(arg);
198                rest.extend(args);
199                break;
200            }
201            let (flag, inline) = match arg.split_once('=') {
202                Some((flag, value)) if flag == "--config" || flag == "--set" => {
203                    (flag.to_string(), Some(value.to_string()))
204                }
205                _ => (arg.clone(), None),
206            };
207            match flag.as_str() {
208                "--check-config" => out.check = true,
209                "--config" => {
210                    let value = match inline {
211                        Some(v) => v,
212                        None => args.next().ok_or_else(|| {
213                            ConfigError::Usage("--config expects a path".to_string())
214                        })?,
215                    };
216                    if value.is_empty() {
217                        return Err(ConfigError::Usage("--config expects a path".to_string()));
218                    }
219                    if out.config.is_some() {
220                        return Err(ConfigError::Usage(
221                            "--config given twice; name one base file".to_string(),
222                        ));
223                    }
224                    out.config = Some(PathBuf::from(value));
225                }
226                "--set" => {
227                    let value = match inline {
228                        Some(v) => v,
229                        None => args.next().ok_or_else(|| {
230                            ConfigError::Usage("--set expects PATH=VALUE".to_string())
231                        })?,
232                    };
233                    out.push_set(&value)?;
234                }
235                _ => match arg.strip_prefix("--").and_then(|a| a.split_once('=')) {
236                    Some((name, value)) if !name.is_empty() => {
237                        rest.push(alloc::format!("--{name}"));
238                        rest.push(value.to_string());
239                    }
240                    _ => rest.push(arg),
241                },
242            }
243        }
244        Ok((out, rest))
245    }
246
247    /// Adds one `PATH=VALUE`, as a `--set` after the ones already given.
248    ///
249    /// # Errors
250    /// [`ConfigError::Usage`] without `=` or with an invalid path.
251    pub fn push_set(&mut self, assignment: &str) -> Result<(), ConfigError> {
252        let Some((path, value)) = assignment.split_once('=') else {
253            return Err(ConfigError::Usage(alloc::format!(
254                "--set {assignment}: expected PATH=VALUE"
255            )));
256        };
257        let path = tree_path(path);
258        path_node(&path, value, Origin::Default)?;
259        self.sets.push((path, value.to_string()));
260        Ok(())
261    }
262
263    /// Appends one map of scalars to the list at `path`, for a program flag
264    /// whose value is a list entry with several settings — a `--set` value
265    /// is always one scalar. `path` is spelled as for `--set`
266    /// (`+types`), `flag` is the flag as the operator wrote it and becomes
267    /// the origin of every field. Entries are merged after every `--set`,
268    /// in the order pushed.
269    ///
270    /// # Errors
271    /// [`ConfigError::Usage`] for an invalid path, a path not written
272    /// `+name`, or a field name that is not one path segment.
273    pub fn push_entry(
274        &mut self,
275        path: &str,
276        fields: &[(&str, &str)],
277        flag: &str,
278    ) -> Result<(), ConfigError> {
279        let path = tree_path(path);
280        if !path
281            .rsplit('.')
282            .next()
283            .is_some_and(|last| last.starts_with('+'))
284        {
285            return Err(ConfigError::Usage(alloc::format!(
286                "{flag}: `{path}` appends only when written `+name`"
287            )));
288        }
289        at_path(&path, Node::Map(BTreeMap::new()))?;
290        if let Some((name, _)) = fields
291            .iter()
292            .find(|(name, _)| name.is_empty() || name.contains('.') || name.starts_with('+'))
293        {
294            return Err(ConfigError::Usage(alloc::format!(
295                "{flag}: `{name}` is not a setting name"
296            )));
297        }
298        self.entries.push(Entry {
299            path,
300            fields: fields
301                .iter()
302                .map(|(name, value)| ((*name).to_string(), (*value).to_string()))
303                .collect(),
304            flag: flag.to_string(),
305        });
306        Ok(())
307    }
308
309    /// Whether `--check-config` was given.
310    #[must_use]
311    pub fn check(&self) -> bool {
312        self.check
313    }
314
315    /// Selects a base file, as `--config` would.
316    #[must_use]
317    pub fn with_config(mut self, path: impl Into<PathBuf>) -> Self {
318        self.config = Some(path.into());
319        self
320    }
321
322    /// The base file `--config` named.
323    #[must_use]
324    pub fn config_path(&self) -> Option<&std::path::Path> {
325        self.config.as_deref()
326    }
327
328    /// Every `--set`, as `(path, value)`, in the order given. The path keeps
329    /// a leading `+` on its last segment.
330    #[must_use]
331    pub fn sets(&self) -> &[(String, String)] {
332        &self.sets
333    }
334
335    /// The `--set` values, one tree each, in the order given, each value's
336    /// origin naming its flag. Kept apart so that `+name` appends to the
337    /// layers below instead of to an empty tree.
338    ///
339    /// # Errors
340    /// [`ConfigError::Usage`] for an invalid path.
341    pub fn layer(&self) -> Result<Vec<Node>, ConfigError> {
342        let sets = self.sets.iter().map(|(path, value)| {
343            let shown = if is_secret_path(path) {
344                "***"
345            } else {
346                value.as_str()
347            };
348            path_node(
349                path,
350                value,
351                Origin::Cli(alloc::format!("--set {path}={shown}")),
352            )
353        });
354        let entries = self.entries.iter().map(|entry| {
355            let origin = Origin::Cli(entry.flag.clone());
356            let map = entry
357                .fields
358                .iter()
359                .map(|(name, value)| (name.clone(), Node::Scalar(value.clone(), origin.clone())))
360                .collect();
361            at_path(&entry.path, Node::Map(map))
362        });
363        sets.chain(entries).collect()
364    }
365
366    /// Merges [`Self::layer`] onto `config` as its topmost source. Nothing is
367    /// pushed when no `--set` was given, so [`Config::sources`] stays the
368    /// list of what actually contributed.
369    ///
370    /// # Errors
371    /// Whatever [`Config::push_each`] reports.
372    pub fn apply(&self, config: &mut Config) -> Result<(), ConfigError> {
373        if self.sets.is_empty() && self.entries.is_empty() {
374            return Ok(());
375        }
376        config.push_each(self.layer()?, CLI_SOURCE.to_string())
377    }
378}
379
380/// A [`Config`] holding only the environment layer, ready for
381/// [`crate::Loader::load_onto`]. Empty and without a source entry when no
382/// variable of `table` is set.
383///
384/// # Errors
385/// Whatever [`env_layer`] reports.
386pub fn env_config<F, L>(
387    table: &[(&str, &str)],
388    lookup: F,
389    is_list: L,
390) -> Result<Config, ConfigError>
391where
392    F: Fn(&str) -> Option<String>,
393    L: Fn(&str) -> bool,
394{
395    let mut config = Config::default();
396    let node = env_layer(table, lookup, is_list)?;
397    if matches!(&node, Node::Map(m) if !m.is_empty()) {
398        config.push(node, ENV_SOURCE.to_string())?;
399    }
400    Ok(config)
401}
402
403/// Where a platform keeps the system configuration of one ZeroDDS program.
404///
405/// The directories are those of `docs/specs/zerodds-deployment-1.0.md`:
406/// `/etc/zerodds` on Linux and other Unix systems (§2.1),
407/// `/usr/local/etc/zerodds` on macOS (§3.1), `%PROGRAMDATA%\ZeroDDS` on
408/// Windows (§4.1). Linux adds the volatile `/run/zerodds/<stem>.d` after the
409/// persistent drop-ins, for values a unit writes at boot.
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct SystemLayout {
412    /// The base file, `<dir>/<stem>.yaml`.
413    pub base: PathBuf,
414    /// Drop-in directories, read in this order after the base.
415    pub drop_ins: Vec<PathBuf>,
416}
417
418impl SystemLayout {
419    /// The platform's configuration directory.
420    #[must_use]
421    pub fn dir() -> PathBuf {
422        #[cfg(windows)]
423        {
424            let root = std::env::var_os("PROGRAMDATA")
425                .map_or_else(|| PathBuf::from(r"C:\ProgramData"), PathBuf::from);
426            root.join("ZeroDDS")
427        }
428        #[cfg(target_os = "macos")]
429        {
430            PathBuf::from("/usr/local/etc/zerodds")
431        }
432        #[cfg(not(any(windows, target_os = "macos")))]
433        {
434            PathBuf::from("/etc/zerodds")
435        }
436    }
437
438    /// The layout for base name `stem`: `zerodds` for the DDS runtime,
439    /// `ws-bridged` for the WebSocket bridge.
440    #[must_use]
441    pub fn for_stem(stem: &str) -> Self {
442        let dir = Self::dir();
443        #[allow(unused_mut)]
444        let mut drop_ins = alloc::vec![dir.join(alloc::format!("{stem}.d"))];
445        #[cfg(target_os = "linux")]
446        drop_ins.push(PathBuf::from("/run/zerodds").join(alloc::format!("{stem}.d")));
447        Self {
448            base: dir.join(alloc::format!("{stem}.yaml")),
449            drop_ins,
450        }
451    }
452
453    /// A loader for this layout. The base is optional: an empty system
454    /// directory means the built-in defaults.
455    #[must_use]
456    pub fn loader(&self) -> crate::Loader {
457        self.drop_ins
458            .iter()
459            .fold(crate::Loader::new().base(&self.base), |l, d| {
460                l.drop_in_dir(d)
461            })
462    }
463}
464
465/// The loader a command line selects: `--config PATH` is a required base
466/// with its sibling `<stem>.d`, otherwise the system layout for `stem`.
467#[must_use]
468pub fn loader_for(command_line: &CommandLine, stem: &str) -> crate::Loader {
469    match command_line.config_path() {
470        Some(path) => crate::Loader::new()
471            .base(path)
472            .require_base()
473            .drop_in_dir(path.with_extension("d")),
474        None => SystemLayout::for_stem(stem).loader(),
475    }
476}
477
478/// Every scalar path in `config` that `known` does not accept, with where it
479/// came from. A sequence element is reported by its sequence's path.
480///
481/// This is the typo check: `discovery.peer` instead of `discovery.peers` is
482/// valid text and a value no program reads.
483#[must_use]
484pub fn unknown_keys<K>(config: &Config, known: K) -> Vec<(String, Origin)>
485where
486    K: Fn(&str) -> bool,
487{
488    let mut out: Vec<(String, Origin)> = Vec::new();
489    for (path, origin) in crate::origins(config) {
490        let path = path.split('[').next().unwrap_or_default().to_string();
491        if !known(&path) && !out.iter().any(|(p, _)| *p == path) {
492            out.push((path, origin));
493        }
494    }
495    out
496}
497
498/// Whether a setting holds a secret, judged by its path segments:
499/// `auth.bearer_token`, `mqtt.password`, `oscore.master_secret`, and every
500/// entry under `auth.sasl_users` (user name to password). A report or an
501/// origin shows `***` in place of such a value. A path to a key file
502/// (`tls.key_file`) is not a secret; its contents are.
503#[must_use]
504pub fn is_secret_path(path: &str) -> bool {
505    path.split('.').any(|segment| {
506        let segment = segment
507            .split('[')
508            .next()
509            .unwrap_or_default()
510            .trim_start_matches('+')
511            .to_ascii_lowercase();
512        [
513            "token",
514            "password",
515            "secret",
516            "passphrase",
517            "psk",
518            "sasl_users",
519            "credentials",
520        ]
521        .iter()
522        .any(|word| segment.contains(word))
523    })
524}