Skip to main content

zerodds_config/
load.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Loading order: a base file, sorted drop-in directories, nested includes.
4
5extern crate alloc;
6
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use std::path::{Path, PathBuf};
10
11use crate::{Config, ConfigError, Node};
12
13/// How deep `include:` may nest before the loader stops.
14///
15/// A bound and not a guess: cycle detection already refuses a chain that
16/// returns to a file it is reading, so this catches the other shape — a
17/// generator that emits `a` including `b` including `c` for ever without
18/// repeating a name. Sixteen is past any hand-written layout.
19pub const MAX_INCLUDE_DEPTH: usize = 16;
20
21/// Builds a [`Config`] out of a base file and drop-in directories.
22///
23/// ```no_run
24/// # use zerodds_config::Loader;
25/// let config = Loader::new()
26///     .base("/etc/zerodds/zerodds.yaml")
27///     .drop_in_dir("/etc/zerodds/zerodds.d")
28///     .drop_in_dir("/run/zerodds/zerodds.d")
29///     .load()?;
30/// // Where did this come from?
31/// println!("{:?}", config.origin_of("transport.mtu"));
32/// # Ok::<(), zerodds_config::ConfigError>(())
33/// ```
34#[derive(Debug, Default, Clone)]
35pub struct Loader {
36    base: Option<PathBuf>,
37    drop_in_dirs: Vec<PathBuf>,
38    extension: Option<String>,
39    missing_base_is_error: bool,
40}
41
42impl Loader {
43    /// A loader with nothing in it yet.
44    #[must_use]
45    pub fn new() -> Self {
46        Self {
47            extension: Some("yaml".to_string()),
48            ..Self::default()
49        }
50    }
51
52    /// The base file, read before every drop-in.
53    ///
54    /// Absent by default: a package can ship only drop-ins, and a site can
55    /// run with only a base.
56    #[must_use]
57    pub fn base(mut self, path: impl AsRef<Path>) -> Self {
58        self.base = Some(path.as_ref().to_path_buf());
59        self
60    }
61
62    /// Fail rather than continue when the base file is not there.
63    ///
64    /// Off by default, because "no configuration" is a legitimate state and a
65    /// daemon that starts on its built-in defaults is what an empty
66    /// `/etc/zerodds` should produce. On for a caller who passed `--config`
67    /// and means it.
68    #[must_use]
69    pub fn require_base(mut self) -> Self {
70        self.missing_base_is_error = true;
71        self
72    }
73
74    /// Adds a drop-in directory, after the ones already added.
75    ///
76    /// Directories are read in the order they are added — a package
77    /// directory, then a site one, then a runtime one — and inside each
78    /// directory the files are read sorted by name. `10-net.yaml` before
79    /// `20-net.yaml` before `99-local.yaml`, which is the ordering every
80    /// systemd drop-in already follows and therefore the one an operator will
81    /// assume without being told.
82    ///
83    /// A directory that does not exist is not an error. Half the point of
84    /// drop-ins is that `/run/...` exists only sometimes.
85    #[must_use]
86    pub fn drop_in_dir(mut self, path: impl AsRef<Path>) -> Self {
87        self.drop_in_dirs.push(path.as_ref().to_path_buf());
88        self
89    }
90
91    /// Only files with this extension are read from drop-in directories.
92    ///
93    /// `yaml` by default. `None` reads every file, which is what a directory
94    /// of `.conf` fragments needs. Either way `.rpmnew`, `.dpkg-dist` and
95    /// editor backups stay out when the default is kept — a package upgrade
96    /// leaving `zerodds.yaml.rpmnew` behind must not quietly change what runs.
97    #[must_use]
98    pub fn extension(mut self, ext: Option<&str>) -> Self {
99        self.extension = ext.map(ToString::to_string);
100        self
101    }
102
103    /// Reads everything and merges it.
104    ///
105    /// # Errors
106    /// [`ConfigError::Io`] for an unreadable file (and for a missing base
107    /// after [`Self::require_base`]), [`ConfigError::Syntax`] for text outside
108    /// the subset, [`ConfigError::IncludeCycle`] and
109    /// [`ConfigError::IncludeTooDeep`] for include chains that do not end.
110    pub fn load(&self) -> Result<Config, ConfigError> {
111        self.load_onto(Config::default())
112    }
113
114    /// Reads everything and merges it over `config`, which keeps its sources
115    /// and loses every value a file sets again. The environment layer goes in
116    /// here, so that the files win over it and a drop-in's `+name` appends to
117    /// what it set.
118    ///
119    /// # Errors
120    /// As [`Self::load`].
121    pub fn load_onto(&self, mut config: Config) -> Result<Config, ConfigError> {
122        if let Some(base) = &self.base {
123            if base
124                .try_exists()
125                .map_err(|error| ConfigError::Io(base.display().to_string(), error.to_string()))?
126            {
127                self.read_into(&mut config, base, &mut Vec::new(), 0)?;
128            } else if self.missing_base_is_error {
129                return Err(ConfigError::Io(
130                    base.display().to_string(),
131                    "no such file".to_string(),
132                ));
133            }
134        }
135        for dir in &self.drop_in_dirs {
136            for file in self.sorted_files(dir)? {
137                self.read_into(&mut config, &file, &mut Vec::new(), 0)?;
138            }
139        }
140        Ok(config)
141    }
142
143    /// The files of one drop-in directory, sorted by name.
144    ///
145    /// Sorted by the file name and not by what the filesystem happens to
146    /// return: `read_dir` gives no order, and a configuration whose result
147    /// depends on inode layout is not a configuration.
148    fn sorted_files(&self, dir: &Path) -> Result<Vec<PathBuf>, ConfigError> {
149        let entries = match std::fs::read_dir(dir) {
150            Ok(e) => e,
151            // A drop-in directory that is not there contributes nothing.
152            Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
153            Err(e) => return Err(ConfigError::Io(dir.display().to_string(), e.to_string())),
154        };
155        let mut files: Vec<PathBuf> = Vec::new();
156        for entry in entries {
157            let entry =
158                entry.map_err(|e| ConfigError::Io(dir.display().to_string(), e.to_string()))?;
159            let path = entry.path();
160            if let Some(want) = &self.extension {
161                if path.extension().and_then(|e| e.to_str()) != Some(want.as_str()) {
162                    continue;
163                }
164            }
165            let metadata = std::fs::metadata(&path)
166                .map_err(|e| ConfigError::Io(path.display().to_string(), e.to_string()))?;
167            if !metadata.is_file() {
168                continue;
169            }
170            files.push(path);
171        }
172        files.sort();
173        Ok(files)
174    }
175
176    /// Reads one file or explicitly included directory, resolving includes
177    /// before the including file's own keys. Directory entries are not
178    /// recursively discovered: further directories require another include.
179    ///
180    /// zerodds-lint: recursion-depth 16
181    fn read_into(
182        &self,
183        config: &mut Config,
184        path: &Path,
185        chain: &mut Vec<String>,
186        depth: usize,
187    ) -> Result<(), ConfigError> {
188        if depth > MAX_INCLUDE_DEPTH {
189            return Err(ConfigError::IncludeTooDeep(MAX_INCLUDE_DEPTH));
190        }
191        let shown = path.display().to_string();
192        // Canonicalise for the comparison only: two names for one file are
193        // still a cycle, and the operator wants to read the names they wrote.
194        let identity = std::fs::canonicalize(path)
195            .map(|p| p.display().to_string())
196            .unwrap_or_else(|_| shown.clone());
197        if chain.contains(&identity) {
198            let mut names = chain.clone();
199            names.push(identity);
200            return Err(ConfigError::IncludeCycle(names));
201        }
202        chain.push(identity);
203
204        // Unlike optional top-level drop-ins, explicitly included paths must
205        // exist. Resolve metadata before sorted_files, whose API deliberately
206        // treats a missing top-level directory as empty.
207        let metadata =
208            std::fs::metadata(path).map_err(|e| ConfigError::Io(shown.clone(), e.to_string()))?;
209        if metadata.is_dir() {
210            for file in self.sorted_files(path)? {
211                self.read_into(config, &file, chain, depth + 1)?;
212            }
213            chain.pop();
214            return Ok(());
215        }
216
217        let text = std::fs::read_to_string(path)
218            .map_err(|e| ConfigError::Io(shown.clone(), e.to_string()))?;
219        let mut node = crate::parse_str(&text, &shown)?;
220
221        // Includes are defaults for this file, regardless of where the
222        // include key appears in its text. Their sequence order is retained.
223        let includes = take_includes(&mut node, &shown)?;
224        for relative in includes {
225            let resolved = resolve_include(path, &relative);
226            self.read_into(config, &resolved, chain, depth + 1)?;
227        }
228        config.push(node, shown)?;
229        chain.pop();
230        Ok(())
231    }
232}
233
234/// Removes the `include:` key and returns what it named.
235fn take_includes(node: &mut Node, path: &str) -> Result<Vec<String>, ConfigError> {
236    let Node::Map(map) = node else {
237        return Ok(Vec::new());
238    };
239    match map.remove("include") {
240        None => Ok(Vec::new()),
241        Some(Node::Seq(items)) => items
242            .into_iter()
243            .map(|item| include_path(item, path))
244            .collect(),
245        Some(one) => Ok(alloc::vec![include_path(one, path)?]),
246    }
247}
248
249/// An invalid directive cannot silently remove a requested configuration
250/// layer. Containers have no single stored source line, so diagnostics for
251/// those point at the file, line 1; scalar diagnostics retain their line.
252fn include_path(node: Node, path: &str) -> Result<String, ConfigError> {
253    let line = match node.origin() {
254        Some(crate::Origin::File { line, .. }) => *line,
255        _ => 1,
256    };
257    if let Node::Scalar(value, _) = node {
258        if !value.trim().is_empty() {
259            return Ok(value);
260        }
261    }
262    Err(ConfigError::Syntax {
263        path: path.to_string(),
264        line,
265        message: "include expects a nonempty path or a sequence of nonempty paths".to_string(),
266    })
267}
268
269/// An include is relative to the file that wrote it, absolute stays absolute.
270fn resolve_include(from: &Path, relative: &str) -> PathBuf {
271    let candidate = Path::new(relative);
272    if candidate.is_absolute() {
273        return candidate.to_path_buf();
274    }
275    from.parent()
276        .unwrap_or_else(|| Path::new("."))
277        .join(candidate)
278}
279
280/// Every path in a tree, dotted, with the origin of its scalar.
281///
282/// The operator-facing view: one line per value, saying where it came from.
283#[must_use]
284pub fn origins(config: &Config) -> Vec<(String, crate::Origin)> {
285    let mut out = Vec::new();
286    if let Some(root) = config.root() {
287        walk(root, &mut String::new(), &mut out);
288    }
289    out
290}
291
292/// zerodds-lint: recursion-depth 32
293///
294/// Bounded by construction: the tree it walks came out of `parse_str`, which
295/// refuses past [`crate::MAX_NESTING_DEPTH`]. The guard is here anyway, since
296/// a caller may hand in a tree it built itself.
297fn walk(node: &Node, prefix: &mut String, out: &mut Vec<(String, crate::Origin)>) {
298    walk_at(node, prefix, out, 0);
299}
300
301/// zerodds-lint: recursion-depth 32
302fn walk_at(node: &Node, prefix: &mut String, out: &mut Vec<(String, crate::Origin)>, depth: usize) {
303    if depth > crate::MAX_NESTING_DEPTH {
304        return;
305    }
306    match node {
307        Node::Scalar(_, origin) => out.push((prefix.clone(), origin.clone())),
308        Node::Map(map) => {
309            for (key, value) in map {
310                let mark = prefix.len();
311                if !prefix.is_empty() {
312                    prefix.push('.');
313                }
314                prefix.push_str(key);
315                walk_at(value, prefix, out, depth + 1);
316                prefix.truncate(mark);
317            }
318        }
319        Node::Seq(items) => {
320            for (i, item) in items.iter().enumerate() {
321                let mark = prefix.len();
322                prefix.push_str(&alloc::format!("[{i}]"));
323                walk_at(item, prefix, out, depth + 1);
324                prefix.truncate(mark);
325            }
326        }
327    }
328}