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}